diff --git a/.gitattributes b/.gitattributes index f6263094d01a2..422796f19d4d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,3 +9,5 @@ ThirdPartyNotices.txt eol=crlf *.sh eol=lf *.rtf -text **/*.json linguist-language=jsonc +build/npm/copilot-sdk-canvas.patch -text -whitespace +build/npm/copilot-sdk-canvas.source.patch -text -whitespace diff --git a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts index e5dbc06aa946e..600fed694bd51 100644 --- a/build/azure-pipelines/common/computeNodeModulesCacheKey.ts +++ b/build/azure-pipelines/common/computeNodeModulesCacheKey.ts @@ -6,6 +6,7 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { dirs } from '../../npm/dirs.ts'; +import { postinstallInputFiles } from '../../npm/installStateHash.ts'; const ROOT = path.join(import.meta.dirname, '../../../'); @@ -16,6 +17,11 @@ shasum.update(fs.readFileSync(path.join(ROOT, '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'build', '.npmrc'))); shasum.update(fs.readFileSync(path.join(ROOT, 'remote', '.npmrc'))); +for (const file of postinstallInputFiles) { + shasum.update(file); + shasum.update(fs.readFileSync(path.join(ROOT, file))); +} + // Add `package.json` and `package-lock.json` files for (const dir of dirs) { const packageJsonPath = path.join(ROOT, dir, 'package.json'); diff --git a/build/filters.ts b/build/filters.ts index c62c8260e72e0..ff0c2996c9c52 100644 --- a/build/filters.ts +++ b/build/filters.ts @@ -101,6 +101,8 @@ export const indentationFilter = Object.freeze([ '!resources/linux/snap/electron-launch', '!build/ext.js', '!build/darwin/patch-dmg.py', + '!build/npm/copilot-sdk-canvas.json', + '!build/npm/copilot-sdk-canvas{,.source}.patch', '!build/npm/gyp/patches/gyp_spectre_mitigation_support.patch', '!product.overrides.json', '!src/vs/platform/endpoint/common/licenseAgreement.ts', diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 691f8cd516478..971f795043c6d 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -692,6 +692,10 @@ "name": "vs/sessions/contrib/applyCommitsToParentRepo", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/canvases", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/changes", "project": "vscode-sessions" diff --git a/build/lib/test/copilotSdkCanvasPatch.test.ts b/build/lib/test/copilotSdkCanvasPatch.test.ts new file mode 100644 index 0000000000000..8c89061d72139 --- /dev/null +++ b/build/lib/test/copilotSdkCanvasPatch.test.ts @@ -0,0 +1,543 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createHash } from 'crypto'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { suite, test, type TestContext } from 'node:test'; +import typescriptPackage from 'typescript/package.json' with { type: 'json' }; +import { ensureCopilotSdkCanvasPatch, type CopilotSdkCanvasPatchManifest } from '../../npm/copilotSdkCanvasPatch.ts'; +import { dirs } from '../../npm/dirs.ts'; +import { collectInputFiles, computeState } from '../../npm/installStateHash.ts'; + +const before = { + 'package.json': JSON.stringify({ + name: '@github/copilot-sdk', + version: '1.0.13', + type: 'module', + exports: { '.': { types: './dist/index.d.ts', import: './dist/client.js', require: './dist/cjs/client.js' } }, + }) + '\n', + 'README.md': 'SDK carrier fixture, not the Copilot runtime.\n', + 'dist/client.js': 'export const canvas = false;\n', + 'dist/index.d.ts': 'export declare const canvas: false;\n', + 'dist/cjs/client.js': 'exports.canvas = false;\n', + 'dist/cjs/package.json': '{"type":"commonjs"}\n', +}; +const after = { + ...before, + 'dist/client.js': 'export const canvas = true;\n', + 'dist/index.d.ts': 'export declare const canvas: true;\n', + 'dist/cjs/client.js': 'exports.canvas = true;\n', + 'dist/helper.js': 'export const retained = null;\n', +}; +const patch = [ + 'diff --git a/dist/client.js b/dist/client.js', + '--- a/dist/client.js', + '+++ b/dist/client.js', + '@@ -1 +1 @@', + '-export const canvas = false;', + '+export const canvas = true;', + 'diff --git a/dist/index.d.ts b/dist/index.d.ts', + '--- a/dist/index.d.ts', + '+++ b/dist/index.d.ts', + '@@ -1 +1 @@', + '-export declare const canvas: false;', + '+export declare const canvas: true;', + 'diff --git a/dist/cjs/client.js b/dist/cjs/client.js', + '--- a/dist/cjs/client.js', + '+++ b/dist/cjs/client.js', + '@@ -1 +1 @@', + '-exports.canvas = false;', + '+exports.canvas = true;', + 'diff --git a/dist/helper.js b/dist/helper.js', + 'new file mode 100644', + '--- /dev/null', + '+++ b/dist/helper.js', + '@@ -0,0 +1 @@', + '+export const retained = null;', + '', +].join('\n'); + +function hash(contents: string): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function hashes(files: Readonly>): Readonly> { + return Object.fromEntries(Object.entries(files).map(([file, contents]) => [file, hash(contents)])); +} + +function writeFiles(directory: string, files: Readonly>): void { + for (const [file, contents] of Object.entries(files)) { + const target = path.join(directory, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, contents); + } +} + +function fixture(t: TestContext) { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-sdk-canvas-patch-'))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const scopes = ['', 'remote']; + const packages = scopes.map(scope => path.join(root, scope, 'node_modules', '@github', 'copilot-sdk')); + for (const directory of packages) { + writeFiles(directory, before); + } + const manifestPath = path.join(root, 'build', 'npm', 'copilot-sdk-canvas.json'); + const manifest: CopilotSdkCanvasPatchManifest = { + schemaVersion: 1, + packageName: '@github/copilot-sdk', + packageVersion: '1.0.13', + patchFile: 'copilot-sdk-canvas.patch', + patchSha256: hash(patch), + before: hashes(before), + after: hashes(after), + }; + const saveManifest = (value: CopilotSdkCanvasPatchManifest) => writeFiles(root, { + 'build/npm/copilot-sdk-canvas.json': JSON.stringify(value), + }); + saveManifest(manifest); + writeFiles(root, { 'build/npm/copilot-sdk-canvas.patch': patch }); + return { root, packages, manifestPath, manifest, saveManifest }; +} + +function readFiles(directory: string, files: Readonly>): Record { + return Object.fromEntries(Object.keys(files).map(file => [file, fs.readFileSync(path.join(directory, file), 'utf8')])); +} + +suite('Copilot SDK canvas dependency patch', () => { + test('installs both complete packages and verifies repeated/cached application without rewriting', t => { + const data = fixture(t); + const applied = ensureCopilotSdkCanvasPatch(data.root); + const timestamp = new Date(1000); + for (const directory of data.packages) { + fs.utimesSync(path.join(directory, 'dist/client.js'), timestamp, timestamp); + } + const repeated = ensureCopilotSdkCanvasPatch(data.root); + const checked = ensureCopilotSdkCanvasPatch(data.root, { checkOnly: true }); + assert.deepStrictEqual({ + applied: applied.map(item => item.status), + repeated: repeated.map(item => item.status), + checked: checked.map(item => item.status), + files: data.packages.map(directory => readFiles(directory, after)), + timestamps: data.packages.map(directory => fs.statSync(path.join(directory, 'dist/client.js')).mtimeMs), + siblings: data.packages.map(directory => fs.readdirSync(path.dirname(directory))), + }, { + applied: ['applied', 'applied'], + repeated: ['verified', 'verified'], + checked: ['verified', 'verified'], + files: [after, after], + timestamps: [1000, 1000], + siblings: [['copilot-sdk'], ['copilot-sdk']], + }); + }); + + test('check-only refuses an unpatched install without writing it', t => { + const data = fixture(t); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root, { checkOnly: true }), /not installed/); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, before)), [before, before]); + }); + + test('a corrupt second target prevents modification of the first target', t => { + const data = fixture(t); + fs.writeFileSync(path.join(data.packages[1], 'dist/client.js'), 'unexpected\n'); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /Unexpected or partially patched/); + assert.deepStrictEqual(readFiles(data.packages[0], before), before); + }); + + test('rejects a partially patched package', t => { + const data = fixture(t); + fs.writeFileSync(path.join(data.packages[0], 'dist/index.d.ts'), after['dist/index.d.ts']); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /Unexpected or partially patched/); + assert.strictEqual(fs.readFileSync(path.join(data.packages[0], 'dist/client.js'), 'utf8'), before['dist/client.js']); + }); + + test('rejects a corrupt delta before touching packages', t => { + const data = fixture(t); + fs.appendFileSync(path.join(path.dirname(data.manifestPath), 'copilot-sdk-canvas.patch'), 'corrupt'); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /recorded SHA-256/); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, before)), [before, before]); + }); + + test('a mismatched postimage leaves both originals intact and removes staging', t => { + const data = fixture(t); + data.saveManifest({ ...data.manifest, after: { ...data.manifest.after, 'dist/helper.js': hash('different\n') } }); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /expected complete package/); + assert.deepStrictEqual({ + files: data.packages.map(directory => readFiles(directory, before)), + siblings: data.packages.map(directory => fs.readdirSync(path.dirname(directory))), + }, { files: [before, before], siblings: [['copilot-sdk'], ['copilot-sdk']] }); + }); + + test('restores the original package when replacement fails', t => { + const data = fixture(t); + const failure = new Error('Replacement failed'); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root, { + fileOperations: { + renameSync: (source, target) => { + if (target === data.packages[0] && path.basename(source.toString()) === 'package') { + throw failure; + } + fs.renameSync(source, target); + }, + rmSync: fs.rmSync, + }, + }), error => error === failure); + assert.deepStrictEqual({ + files: data.packages.map(directory => readFiles(directory, before)), + siblings: data.packages.map(directory => fs.readdirSync(path.dirname(directory))), + }, { files: [before, before], siblings: [['copilot-sdk'], ['copilot-sdk']] }); + }); + + test('preserves the original backup and both errors when rollback fails', t => { + const data = fixture(t); + const replacementFailure = new Error('Replacement failed'); + const rollbackFailure = new Error('Rollback failed'); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root, { + fileOperations: { + renameSync: (source, target) => { + if (target === data.packages[0]) { + throw path.basename(source.toString()) === 'package' ? replacementFailure : rollbackFailure; + } + fs.renameSync(source, target); + }, + rmSync: fs.rmSync, + }, + }), error => { + assert.ok(error instanceof AggregateError); + assert.deepStrictEqual(error.errors, [replacementFailure, rollbackFailure]); + assert.match(error.message, /original package is retained/); + return true; + }); + const stagingName = fs.readdirSync(path.dirname(data.packages[0])).find(name => name.startsWith('.copilot-sdk-canvas-')); + assert.ok(stagingName); + const staging = path.join(path.dirname(data.packages[0]), stagingName); + assert.deepStrictEqual({ + packageExists: fs.existsSync(data.packages[0]), + original: readFiles(path.join(staging, 'original'), before), + candidate: readFiles(path.join(staging, 'package'), after), + remote: readFiles(data.packages[1], before), + }, { packageExists: false, original: before, candidate: after, remote: before }); + }); + + test('reports cleanup failure without hiding the original preparation error', t => { + const data = fixture(t); + const cleanupFailure = new Error('Cleanup failed'); + data.saveManifest({ ...data.manifest, after: { ...data.manifest.after, 'dist/helper.js': hash('different\n') } }); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root, { + fileOperations: { renameSync: fs.renameSync, rmSync: () => { throw cleanupFailure; } }, + }), error => { + assert.ok(error instanceof AggregateError); + assert.strictEqual(error.errors.length, 2); + assert.ok(error.errors[0] instanceof Error); + assert.match(error.errors[0].message, /expected complete package/); + assert.strictEqual(error.errors[1], cleanupFailure); + return true; + }); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, before)), [before, before]); + }); + + test('surfaces post-replacement cleanup failure and permits a later repair', t => { + const data = fixture(t); + const cleanupFailure = new Error('Cleanup failed'); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root, { + fileOperations: { renameSync: fs.renameSync, rmSync: () => { throw cleanupFailure; } }, + }), error => error === cleanupFailure); + const stagingName = fs.readdirSync(path.dirname(data.packages[0])).find(name => name.startsWith('.copilot-sdk-canvas-')); + assert.ok(stagingName); + const backup = path.join(path.dirname(data.packages[0]), stagingName, 'original'); + assert.deepStrictEqual({ + root: readFiles(data.packages[0], after), + remote: readFiles(data.packages[1], before), + backup: readFiles(backup, before), + }, { root: after, remote: before, backup: before }); + assert.deepStrictEqual({ + statuses: ensureCopilotSdkCanvasPatch(data.root).map(item => item.status), + files: data.packages.map(directory => readFiles(directory, after)), + backup: readFiles(backup, before), + }, { statuses: ['verified', 'applied'], files: [after, after], backup: before }); + }); + + test('includes an existing distro remote dependency tree', t => { + const data = fixture(t); + const distro = path.join(data.root, '.build', 'distro', 'npm', 'remote', 'node_modules', '@github', 'copilot-sdk'); + writeFiles(distro, before); + assert.deepStrictEqual({ + applied: ensureCopilotSdkCanvasPatch(data.root).map(item => item.status), + checked: ensureCopilotSdkCanvasPatch(data.root, { checkOnly: true }).map(item => item.status), + files: [...data.packages, distro].map(directory => readFiles(directory, after)), + }, { applied: ['applied', 'applied', 'applied'], checked: ['verified', 'verified', 'verified'], files: [after, after, after] }); + }); + + test('an incomplete distro remote tree prevents modification of every target', t => { + const data = fixture(t); + fs.mkdirSync(path.join(data.root, '.build', 'distro', 'npm', 'remote'), { recursive: true }); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /Missing dependency directory/); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, before)), [before, before]); + }); + + test('rejects undeclared nested-dependency changes before copying or patching', t => { + const data = fixture(t); + for (const directory of data.packages) { + writeFiles(directory, { 'node_modules/untouched/package.json': '{"name":"untouched"}\n' }); + } + const modifiedPatch = patch + [ + 'diff --git a/node_modules/untouched/package.json b/node_modules/untouched/package.json', + '--- a/node_modules/untouched/package.json', + '+++ b/node_modules/untouched/package.json', + '@@ -1 +1 @@', + '-{"name":"untouched"}', + '+{"name":"changed"}', + '', + ].join('\n'); + fs.writeFileSync(path.join(path.dirname(data.manifestPath), 'copilot-sdk-canvas.patch'), modifiedPatch); + data.saveManifest({ ...data.manifest, patchSha256: hash(modifiedPatch) }); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /unexpected file/); + assert.deepStrictEqual(data.packages.map(directory => ({ + files: readFiles(directory, before), + dependency: fs.readFileSync(path.join(directory, 'node_modules', 'untouched', 'package.json'), 'utf8'), + })), [ + { files: before, dependency: '{"name":"untouched"}\n' }, + { files: before, dependency: '{"name":"untouched"}\n' }, + ]); + }); + + test('rejects symlink creation in the generated delta', t => { + const data = fixture(t); + const modifiedPatch = patch.replace('new file mode 100644', 'new file mode 120000'); + fs.writeFileSync(path.join(path.dirname(data.manifestPath), 'copilot-sdk-canvas.patch'), modifiedPatch); + data.saveManifest({ ...data.manifest, patchSha256: hash(modifiedPatch) }); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /cannot contain symlinks/); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, before)), [before, before]); + }); + + test('rejects path traversal and package metadata replacement', t => { + const data = fixture(t); + data.saveManifest({ ...data.manifest, after: { ...data.manifest.after, '../outside': hash('x') } }); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /Invalid .* manifest/); + data.saveManifest({ ...data.manifest, after: { ...data.manifest.after, 'package.json': hash('{}') } }); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /preserve published package metadata/); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, before)), [before, before]); + }); + + for (const link of ['node_modules', 'package', 'sdk-directory']) { + test(`refuses a ${link} symlink without modifying its target`, t => { + const data = fixture(t); + const directory = link === 'node_modules' + ? path.join(data.root, 'node_modules') + : link === 'package' ? data.packages[0] : path.join(data.packages[0], 'dist'); + const original = path.join(data.root, 'original'); + fs.renameSync(directory, original); + fs.symlinkSync(original, directory, process.platform === 'win32' ? 'junction' : 'dir'); + assert.throws(() => ensureCopilotSdkCanvasPatch(data.root), /symlinked|Unexpected SDK package entry/); + const client = link === 'node_modules' ? ['@github', 'copilot-sdk', 'dist', 'client.js'] + : link === 'package' ? ['dist', 'client.js'] : ['client.js']; + assert.strictEqual( + fs.readFileSync(path.join(original, ...client), 'utf8'), + before['dist/client.js'], + ); + }); + } + + test('preserves nested dependencies without patching or following their links', t => { + const data = fixture(t); + const dependency = path.join(data.root, 'dependency'); + writeFiles(dependency, { 'package.json': '{"name":"untouched"}\n' }); + for (const directory of data.packages) { + fs.mkdirSync(path.join(directory, 'node_modules')); + fs.symlinkSync(dependency, path.join(directory, 'node_modules', 'untouched'), process.platform === 'win32' ? 'junction' : 'dir'); + } + const links = data.packages.map(directory => fs.readlinkSync(path.join(directory, 'node_modules', 'untouched'))); + ensureCopilotSdkCanvasPatch(data.root); + assert.deepStrictEqual(data.packages.map(directory => ({ + link: fs.readlinkSync(path.join(directory, 'node_modules', 'untouched')), + contents: fs.readFileSync(path.join(directory, 'node_modules', 'untouched', 'package.json'), 'utf8'), + })), [ + { link: links[0], contents: '{"name":"untouched"}\n' }, + { link: links[1], contents: '{"name":"untouched"}\n' }, + ]); + }); + + test('the patched package resolves both ESM and CommonJS exports', t => { + const data = fixture(t); + ensureCopilotSdkCanvasPatch(data.root); + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + import { createRequire } from 'node:module'; + import { canvas } from '@github/copilot-sdk'; + const require = createRequire(import.meta.url); + assert.equal(canvas, true); + assert.equal(require('@github/copilot-sdk').canvas, true); + `], { cwd: data.root, encoding: 'utf8' }); + assert.strictEqual(result.status, 0, result.stderr); + }); + + test('the patched declarations resolve through the published export map', t => { + const data = fixture(t); + ensureCopilotSdkCanvasPatch(data.root); + const source = path.join(data.root, 'consumer.mts'); + fs.writeFileSync(source, 'import { canvas } from "@github/copilot-sdk";\nconst supported: true = canvas;\n'); + const compilerEntry = Object.entries(typescriptPackage.bin).find(([name]) => /^tsc\d*$/.test(name)); + assert.ok(compilerEntry, 'The installed TypeScript package must declare a compiler executable.'); + const compiler = path.resolve(path.dirname(fileURLToPath(import.meta.resolve('typescript/package.json'))), compilerEntry[1]); + const result = spawnSync(process.execPath, [compiler, '--noEmit', '--module', 'nodenext', '--target', 'es2024', source], { + cwd: data.root, + encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + }); + + test('install-state inputs include the carrier, payload and both postinstall paths', t => { + const data = fixture(t); + assert.deepStrictEqual( + collectInputFiles(data.root).map(file => path.relative(data.root, file).split(path.sep).join('/')).filter(file => file.startsWith('build/npm/')), + [ + 'build/npm/postinstall.ts', + 'build/npm/fast-install.ts', + 'build/npm/installStateHash.ts', + 'build/npm/copilotSdkCanvasPatch.ts', + 'build/npm/copilot-sdk-canvas.json', + 'build/npm/copilot-sdk-canvas.patch', + ], + ); + }); + + test('changing the generated payload invalidates the install-state hash', t => { + const data = fixture(t); + const first = computeState({ repositoryRoot: data.root }); + fs.appendFileSync(path.join(path.dirname(data.manifestPath), 'copilot-sdk-canvas.patch'), '\n'); + const second = computeState({ repositoryRoot: data.root }); + assert.notStrictEqual(first.fileHashes['build/npm/copilot-sdk-canvas.patch'], second.fileHashes['build/npm/copilot-sdk-canvas.patch']); + }); + + test('CI dependency cache keys bind every postinstall input and reject missing inputs', t => { + const data = fixture(t); + for (const dir of dirs) { + writeFiles(path.join(data.root, dir), { + 'package.json': '{"private":true,"type":"module"}\n', + 'package-lock.json': '{"packages":{}}\n', + '.npmrc': '', + }); + } + writeFiles(data.root, { 'build/.cachesalt': 'canvas-cache-fixture\n' }); + const inputs = collectInputFiles(data.root).map(file => path.relative(data.root, file)).filter(file => file.startsWith(path.join('build', 'npm') + path.sep)); + const calculator = 'build/azure-pipelines/common/computeNodeModulesCacheKey.ts'; + for (const file of [calculator, 'build/npm/dirs.ts', ...inputs.filter(file => file.endsWith('.ts'))]) { + writeFiles(data.root, { [file]: fs.readFileSync(path.resolve(import.meta.dirname, '../../..', file), 'utf8') }); + } + const run = () => spawnSync(process.execPath, [path.join(data.root, calculator), 'compile', process.arch], { + cwd: data.root, + encoding: 'utf8', + }); + const key = () => { + const result = run(); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + return result.stdout; + }; + const initial = key(); + const changed = inputs.map(file => { + const target = path.join(data.root, file); + const contents = fs.readFileSync(target); + fs.appendFileSync(target, '\n'); + const invalidated = key() !== initial; + fs.writeFileSync(target, contents); + return invalidated; + }); + const restored = key(); + fs.unlinkSync(data.manifestPath); + const missing = run(); + assert.deepStrictEqual({ + keyLength: initial.length, + changed, + restored: restored === initial, + missing: { status: missing.status, reported: missing.stderr.includes('copilot-sdk-canvas.json') }, + packages: data.packages.map(directory => readFiles(directory, before)), + }, { + keyLength: 64, + changed: inputs.map(() => true), + restored: true, + missing: { status: 1, reported: true }, + packages: [before, before], + }); + }); + + for (const autocrlf of ['true', 'input', 'false']) { + test(`preserves exact package bytes with Git core.autocrlf=${autocrlf} and core.eol=crlf`, t => { + const data = fixture(t); + const helper = pathToFileURL(path.resolve(import.meta.dirname, '../../npm/copilotSdkCanvasPatch.ts')).href; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import { ensureCopilotSdkCanvasPatch } from ${JSON.stringify(helper)}; + ensureCopilotSdkCanvasPatch(${JSON.stringify(data.root)}); + `], { + cwd: data.root, + env: { + ...process.env, + GIT_CONFIG_COUNT: '2', + GIT_CONFIG_KEY_0: 'core.autocrlf', + GIT_CONFIG_VALUE_0: autocrlf, + GIT_CONFIG_KEY_1: 'core.eol', + GIT_CONFIG_VALUE_1: 'crlf', + }, + encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, after)), [after, after]); + }); + } + + test('ignores an inherited Git context and an enclosing repository', t => { + const data = fixture(t); + const initialized = spawnSync('git', ['init', '--quiet', '--initial-branch=ulugbekna/sdk-patch-fixture', data.root], { encoding: 'utf8' }); + assert.strictEqual(initialized.status, 0, initialized.stderr); + const helper = pathToFileURL(path.resolve(import.meta.dirname, '../../npm/copilotSdkCanvasPatch.ts')).href; + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import { ensureCopilotSdkCanvasPatch } from ${JSON.stringify(helper)}; + ensureCopilotSdkCanvasPatch(${JSON.stringify(data.root)}); + `], { + cwd: data.root, + env: { + ...process.env, + GIT_DIR: path.join(data.root, 'not-a-repository'), + GIT_WORK_TREE: path.join(data.root, 'not-the-package'), + GIT_INDEX_FILE: path.join(data.root, 'unused-index'), + }, + encoding: 'utf8', + }); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, after)), [after, after]); + assert.strictEqual(fs.existsSync(path.join(data.root, 'unused-index')), false); + }); + + test('the real fast-install cached path still installs and verifies the SDK delta', t => { + const data = fixture(t); + writeFiles(data.root, { 'package.json': '{"private":true,"type":"module"}\n', '.nvmrc': process.versions.node }); + for (const file of ['fast-install.ts', 'installStateHash.ts', 'copilotSdkCanvasPatch.ts', 'dirs.ts']) { + fs.copyFileSync(path.resolve(import.meta.dirname, '../../npm', file), path.join(data.root, 'build', 'npm', file)); + } + const hashModule = pathToFileURL(path.join(data.root, 'build', 'npm', 'installStateHash.ts')).href; + const saved = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import { writeFileSync } from 'node:fs'; + import { computeState, stateFile } from ${JSON.stringify(hashModule)}; + writeFileSync(stateFile, JSON.stringify(computeState())); + `], { cwd: data.root, encoding: 'utf8' }); + assert.strictEqual(saved.status, 0, saved.stdout + saved.stderr); + const bin = path.join(data.root, 'bin'); + fs.mkdirSync(bin); + const npm = path.join(bin, process.platform === 'win32' ? 'npm.cmd' : 'npm'); + fs.writeFileSync(npm, process.platform === 'win32' ? '@echo off\r\nexit /b 99\r\n' : '#!/bin/sh\nexit 99\n', { mode: 0o755 }); + const result = spawnSync(process.execPath, [path.join(data.root, 'build', 'npm', 'fast-install.ts')], { + cwd: data.root, + env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ''}` }, + encoding: 'utf8', + timeout: 10000, + }); + assert.strictEqual(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /All dependencies up to date/); + assert.deepStrictEqual(data.packages.map(directory => readFiles(directory, after)), [after, after]); + }); +}); diff --git a/build/lib/test/hygiene.test.ts b/build/lib/test/hygiene.test.ts index b1b4e67404d83..9b2e7c9470e21 100644 --- a/build/lib/test/hygiene.test.ts +++ b/build/lib/test/hygiene.test.ts @@ -10,6 +10,28 @@ import { suite, test } from 'node:test'; suite('hygiene', () => { + test('checks generated canvas SDK inputs without applying source indentation or copyright rules', () => { + const repositoryRoot = path.join(import.meta.dirname, '../../..'); + const result = spawnSync(process.execPath, [ + '--experimental-strip-types', + 'build/hygiene.ts', + 'build/npm/copilot-sdk-canvas.json', + 'build/npm/copilot-sdk-canvas.patch', + 'build/npm/copilot-sdk-canvas.source.patch', + ], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + + assert.deepStrictEqual({ + status: result.status, + checkedBothPayloads: result.stdout.includes('Hygiene checked 2 files'), + }, { + status: 0, + checkedBothPayloads: true, + }); + }); + test('rejects requested files that enter no hygiene checker', () => { const repositoryRoot = path.join(import.meta.dirname, '../../..'); const result = spawnSync(process.execPath, [ diff --git a/build/npm/copilot-sdk-canvas.build.md b/build/npm/copilot-sdk-canvas.build.md new file mode 100644 index 0000000000000..de75fcf156b88 --- /dev/null +++ b/build/npm/copilot-sdk-canvas.build.md @@ -0,0 +1,168 @@ +# Rebuilding the SDK 1.0.13 canvas backport B3 + +B3 is a source-backed private follow-up to the accepted SDK 1.0.13 B2 +candidate, not a new SDK release or bundled runtime upgrade. It fixes +launch-provider cancellation lifecycle cleanup without changing public APIs, +package metadata, dependencies, CLI selection, registration ownership, or +permission policy. + +Relative to B2, only `nodejs/src/extensionLaunchProvider.ts` and its focused +test change. Only `dist/extensionLaunchProvider.js` and +`dist/cjs/extensionLaunchProvider.js` change in the package. All compiler-emitted +declarations, generated RPC bindings, client startup code, retention bindings, +and B2 initial `enableScriptSafety` forwarding remain byte-identical. + +## Immutable inputs and outputs + +| Input or output | Exact revision or SHA-256 | +| --- | --- | +| Public SDK source | `github/copilot-sdk` commit `f13e4a2cc7e4e220974d2333142234e162a3252e` | +| Inherited startup prerequisite | Node-only single-flight guard and two tests from `3dbd843e46771f99070221a85d83c85d8046d0bd` | +| Published npm archive | `https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.13.tgz` | +| Published archive SHA-256 | `238147f38bb7597bdd6864445e27137475ec034c831efb555655f3db28d50b3b` | +| Full portable source patch | `f57c9d4c8d800ed1093c7d9afb41191295bc719b4d11459906e771d5403bb456` | +| Published59-to-B3_62 emitted patch | `1684762ce6cc3d9e8642071aca323886b6f90f915b60c3c9296808faf3368e8a` | +| Published59-to-B3_62 manifest | `90d2825e5033b1a7f4684e806f5a0d7b2966904a9aca8c05f680040d54dbac41` | +| Accepted B2 manifest | `5475d02aecc64ec2a2f2911de9f8a07842edacfc54eca7a2834367f148b3d55a` | +| Narrow B2-to-B3 source patch | `527dad3bf32979564ad552b67365231e90db6e4f1359393ae0b4777b96add5bb` | +| B2_62-to-B3_62 emitted transition | `2e50caf7eece63ba086aac59d26553394b6e8920039cca811b5a72c537afdcfc` | +| B2_62-to-B3_62 transition manifest | `f1d99f8b7767cd2c4bd5981a2fbab8f71fb027d875f896795448a22e308deab5` | + +The portable source patch contains only release-relative `nodejs/` and +`scripts/codegen/typescript.ts` paths. It includes the accepted launch/retain +bindings, R2 synchronous cancellation safety, B2 script-safety forwarding, +and B3 lifecycle fix/tests. It contains no private runtime/app code or original +extension fixtures. Source manifests, lockfiles, README, and CLI pin remain +at the public release commit. + +## Cancellation change + +The declared and actual VS-resolved `vscode-jsonrpc` version is `8.2.1`. +Its lazy `CancellationTokenSource` may install a frozen cancelled singleton +when cancelled before its token getter is accessed; a second cancellation then +attempts a nonexistent `_token.cancel()`. The analogous lazy disposal path +can install a frozen none singleton. + +B3 materializes the owned lifetime token before cancellation and uses its +cancellation state to make disposal idempotent and reentrant. Request tokens +are materialized before either cancellation subscription can invoke its +callback, and overlapping cancellation is guarded by that stable token's +state. The R2 `withCancellation` implementation is unchanged: callback entry +remains synchronous, synchronous errors keep their identity, and cancelled +or late grants remain unusable. There is no broad catch, global rejection +listener, dependency patch, fallback grant, or client cleanup workaround. + +The exact VS-resolved dependency was copied read-only into private test +layouts. Both ESM and CommonJS public-package probes verified their actual +SDK and JSON-RPC resolution paths and all 48 dependency-owned file hashes. +Its `lib/common/cancellation.js` SHA-256 is +`bddf9e8f3bf2db7907d3c2551a690328cc6f978b1342432105dcbd072080e935`. +No installed VS dependency was modified. + +## Exact toolchain and build + +The producer used macOS arm64, Node `24.18.0`, npm `11.16.0`, esbuild `0.28.1`, +TypeScript `5.9.3`, tsx `4.22.4`, Vitest `4.1.8`, and +json-schema-to-typescript `15.0.4`, from the unchanged release lockfile. + +Use a fresh source export and private artifact paths. These commands are +fish-compatible. The archive command reads an existing repository containing +the exact public commit; it does not create a checkout or branch. + +```fish +set source_dir /path/to/empty/source-export +set artifacts_dir /path/to/carrier-artifacts +set archive /path/to/release-source.tar + +git archive --format=tar --output=$archive f13e4a2cc7e4e220974d2333142234e162a3252e +mkdir $source_dir +tar -xf $archive -C $source_dir +env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR git -C $source_dir apply --check --whitespace=error-all $artifacts_dir/copilot-sdk-canvas.source.patch +env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR git -C $source_dir apply --whitespace=error-all $artifacts_dir/copilot-sdk-canvas.source.patch +cd $source_dir/nodejs + +# First try the existing build without installing anything. +env PATH=(string join : $PWD/node_modules/.bin $PATH) node --import=tsx esbuild-copilotsdk-nodejs.ts + +# Only after a missing-dependency failure, restore this export's own lockfile. +# Never install through a shared or sibling node_modules symlink. +npm ci --ignore-scripts --no-audit --no-fund +env PATH=(string join : $PWD/node_modules/.bin $PATH) node --import=tsx esbuild-copilotsdk-nodejs.ts +npm run typecheck +npm test -- test/extension-launch-provider.test.ts +``` + +The unchanged build entry emits ES2022 ESM, CommonJS, and real `tsc` +declarations. `node --import=tsx` avoids the optional tsx CLI IPC socket that +can exceed macOS's socket-path limit in deeply nested private directories; +it does not change the compiler or build flags. The producer restored +dependencies only after an observed missing-esbuild build failure. + +B3 also rebuilt a second fresh release export with the portable source patch. +All 55 emitted files matched the final candidate byte-for-byte and +mode-for-mode. That isolated rebuild reused only B3's own already-restored, +manifest-matched dependencies without installing through the private link. +The earlier reproduction of all 52 original published dist files remains +labeled B1 evidence. A separate B3 comparison build reproduced all 62 +accepted B2 package files before changing the helper. + +Do not hand-edit `dist/` or declarations, rewrite release versions, or copy the +source's `0.0.0-dev` package.json into the published-package image. + +## Package assembly and controlled transitions + +Verify and extract the exact published archive to a fresh regular directory. +Overlay only the built `nodejs/dist/`. Compare every package-owned regular +file, hash, and mode with the manifest's complete `after` and `afterModes`, +excluding nested `node_modules`. + +The full payload maps 59 released files to 62 candidate files: nine changed +existing outputs and three new outputs, all under `dist/`. The seven non-dist +files remain identical: package.json, README.md, and five docs. The published +vector has no LICENSE file. Package version `1.0.13`, CLI pin `1.0.83`, export +map, declared dependency graph, and all eight platform optional dependencies +remain unchanged. + +The main manifest and patch apply to the exact published beforeimage, not +directly to installed B2. The separate `transition/copilot-sdk-canvas.json` +and adjacent patch map the complete accepted B2_62 image to B3_62 and can be +passed through the existing carrier's `manifestPath` input. Only two emitted +JS files differ, but both manifests validate complete images. Do not replace +this with partial-file edits or weakened guards. + +Both package routes were exercised with fresh application, complete before +and after validation, rejection of a second raw application, reverse to the +exact beforeimage, and reapplication. A carrier must recognize the complete +afterimage rather than blindly apply a patch twice. Installation, complete +root/remote replacement, and acceptance remain the consumer owner's actions. + +## Unchanged schema, runtime, and trust boundaries + +B3 does not regenerate schemas. The inherited generated TypeScript rebuilds +without private inputs. Regeneration is separate: released CLI `1.0.83` alone +does not contain the unreleased v1 launch acknowledgement/context and retain +contract. Preserve the reviewed canonical fragments; do not regenerate from +the release schemas and assume the bindings will survive. + +The SDK caller must explicitly select a compatible runtime. The local +qualification used the unchanged frozen R4 runtime candidate, not the +release's bundled CLI. Admission opt-in still requires an explicit v1 +acknowledgement; unsupported negotiation fails closed. Retention and +connection errors remain errors. Runtime connection ownership is unchanged. + +B2's public `enableScriptSafety: true` setting enables read-only shell-command +classification under runtime/managed policy. It is not a security-policy +override, blanket tool approval, extension sandbox, or retroactive protection +for already-running work. Required hosts must supply it before each create +and cold resume. The scalar is not durable through retention: cold omission +defaults false, while resident omission preserves the current in-memory value. + +B3's native scope is unchanged counter/SSE and offline triage domain +retention/cold-resume, plus a deliberately invalid CLI-option startup failure. +The latter preserves the original CLI exit diagnostic and safely repeats +stop/forceStop; it is not a reproduction of the consumer's preview-environment +configuration. Both runs use isolated profiles, blocked live-model traffic, +and exact PID/birth cleanup. Public loopbacks separately force pending-create +retention and cancellation overlaps. Prior B1/B2/R2 results are not relabeled +as B3 runs. No publication, production UI qualification, or automatic +Checkpoint A clearance follows from this candidate. diff --git a/build/npm/copilot-sdk-canvas.json b/build/npm/copilot-sdk-canvas.json new file mode 100644 index 0000000000000..71ae67ca5368c --- /dev/null +++ b/build/npm/copilot-sdk-canvas.json @@ -0,0 +1,287 @@ +{ + "schemaVersion": 1, + "packageName": "@github/copilot-sdk", + "packageVersion": "1.0.13", + "patchFile": "copilot-sdk-canvas.patch", + "patchSha256": "1684762ce6cc3d9e8642071aca323886b6f90f915b60c3c9296808faf3368e8a", + "before": { + "dist/canvas.d.ts": "2f5c82d47a8a1abd27466f9dc093106a59d83f685d4f6c29367e2ce25f44b700", + "dist/canvas.js": "53046d98b9267cad679a021b0e6e7819134750322b1bc80057622ee95f891991", + "dist/cjs/canvas.js": "c201cf7f92a254998436854cbf8aaa22653d7fe57c915115f70a3377696845dc", + "dist/cjs/client.js": "18c0b338f745035d91648c76400719c68de358f189737ca3fa67cde0a45312ea", + "dist/cjs/cliVersion.js": "e7597d95ea76c0a1193c612192d62fdc4e04aebd792d0183de423eed7c2b1852", + "dist/cjs/copilotRequestHandler.js": "83f881916c15dec351e7c860e7a33d443bb06e14bf753c0c93546e8ce8be3425", + "dist/cjs/extension.js": "9d1190c59e8a6cb9b1f08127c59e413d9dff743dc9163d1e3b8a3aa368318827", + "dist/cjs/factory.js": "0080126bf5ce47290eba54f6c42f799d584478ed54075eb684cbff7675768267", + "dist/cjs/ffiRuntimeHost.js": "b0d1c2af9178414c9228383516c0b0341e291d355d584d99040795d42e0535b9", + "dist/cjs/generated/rpc.js": "a837977d7edf63b20c17b73792bb8897c4765c9d380fe712cbaa8a967a0bf14e", + "dist/cjs/generated/session-events.js": "0e225f03a52818fd6407984f86b7d42c98d9c8474be25c4926f3b5cb49b5fbfa", + "dist/cjs/index.js": "2fc8ba8f79e1105ceb06e87823711fb2aa2154855586e641d2433e1d430fbd17", + "dist/cjs/package.json": "dbf8353f77358bc12169b7bb7301e1978d5b503e002ee927229a8993672818fc", + "dist/cjs/runtimeArtifacts.js": "2cbaaa4cf9966b8095db2470f0e837c21c94a4924750eb1eda05770031a56124", + "dist/cjs/sdkProtocolVersion.js": "0442311a93414e3285906ce491adc365f16bbb7b289c214f9f32adca4376067c", + "dist/cjs/session.js": "e2db64a1283315d45026dca0f9db714aa64ae43ccc15ff228a2277664dc45213", + "dist/cjs/sessionFsProvider.js": "89dab8bb5a4851ba65d266f5dbbd1b93bad41f92949303de6c1c8b6f6c19703c", + "dist/cjs/telemetry.js": "83180695ac1d8e2c8db7451d9eaef5d78683abf3bb6a4a3e31ea07c26b7557aa", + "dist/cjs/toolSet.js": "e0edbe438f046ed7811062dccafbcef863145dfd6240f6805670ca24524d1fa6", + "dist/cjs/types.js": "f600e6e38b4e34e5527fc509b4286b71f62927b533697beb848ea6e7b7953a69", + "dist/client.d.ts": "976ef43b72913a493696ad229449231edb0625891b64eff3356be00e77419945", + "dist/client.js": "23be6340fabd555ccd8f0c9fc20786bcb743902f73e0a30e26157a1c55c36abf", + "dist/cliVersion.d.ts": "53c74281b2474d47487e9e9fd04880cacc3549418ea315827941c45ac53b2773", + "dist/cliVersion.js": "0eb84818650f7621cd9da3fd48345337fe33e1b1ac2cdcfffbe1d27aa05c8556", + "dist/copilotRequestHandler.d.ts": "730762ae75ad87be8cbef0080a2f5150919f0a9c53e52b6aaaa3f366f2c17d79", + "dist/copilotRequestHandler.js": "f73b310fab97ccba9282c17cafc579ab545835a72e3b0be79179e95f7221353a", + "dist/extension.d.ts": "a83bc2d74059e6d56331671a33e0d0b6fc12563384e0dd6d279a6ebe45582384", + "dist/extension.js": "4e6da019a4b486d339ee349cb3fad491611c4676b809e82ac861236c35c72c2d", + "dist/factory.d.ts": "f47003bb64004aeb7566a761c8606f7b4416086e03a3fa549797100a93a54d29", + "dist/factory.js": "8e3e436a63f459b111daf0a6727a4751b181c315dcdc79b15def67df3e7cc9cf", + "dist/ffiRuntimeHost.d.ts": "aa7b142d97ac539ac2b7aa1b054d3aa4c297e0617c78e8f4f204662e0f27fa47", + "dist/ffiRuntimeHost.js": "83fa3e587fb964a02c6c3ecb3bb5f5b7082317769515522b8a82b3d943f2cac6", + "dist/generated/rpc.d.ts": "f262cd7e036ea1c862e17f90a72340d918943761413552e6019a96f4aee33c3c", + "dist/generated/rpc.js": "eb50288b9cddd159865318b008265c8da2087a6969823ce14854779ee3365742", + "dist/generated/session-events.d.ts": "c9bde505b270207f6015a83af7e8a6054352f17699ef0c380dc6417433edbc98", + "dist/generated/session-events.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "dist/index.d.ts": "5d6e293c57b2a1be58884dc245b50541e4129b3aa27abb51fb2498b227db8c0b", + "dist/index.js": "8455ad44fe40c1c21c810253b63af70a4722f6a1cec3e7c3db1b18690dc302e0", + "dist/runtimeArtifacts.d.ts": "1d78a2fd37b65e793e553db9a8323a6727b77e7ffbd6b61c462e778794dd76e2", + "dist/runtimeArtifacts.js": "8940ed83b85ee14563a85728cccaac0c5f25c45f3441cce33daf9410297be3cb", + "dist/sdkProtocolVersion.d.ts": "344a1c9669d1617346e2bb71d24081797b69b6cfd37c17b04b2aebf8e7a87260", + "dist/sdkProtocolVersion.js": "4c065062c758839aba7c1f2311886d57acfced0863554b75cc22c275fe9f9fde", + "dist/session.d.ts": "645c3f06dc0206a2963eb6874fd09a0db612a0c00637a0da48cb9462695ad720", + "dist/session.js": "e0bcdd68532f4108906af199531359f422cf192f1e5d61124b82e734ea0f5546", + "dist/sessionFsProvider.d.ts": "6aa732885a832f7117a5d85853eb05b26052bc446a278c17b9463b7bdb06094d", + "dist/sessionFsProvider.js": "703d5c8b64fa805cf303e02fd2ea3bd4df0d649c81983bba0fb08b6c6424c525", + "dist/telemetry.d.ts": "0721ce90ecd3a15eeb86d7787ca62236b1b545b0e93139ed5ef4419b16544722", + "dist/telemetry.js": "dff6ccf631b2508039c41fd69d01cbb344bcb20d83a520e006720d955398ac9f", + "dist/toolSet.d.ts": "41e53ae25809c90fd1a78f493cb912c6a9194c6e0a02e3e32545971c41a1cb56", + "dist/toolSet.js": "8ff3c7953302b99e6c928d715a3a9f21146bd0f544eaa35fb71ebc07227fd7a7", + "dist/types.d.ts": "6b5d15d17d9445b12479dfb9e34e5194397818ec190fa308f8fe9c5b05d5e63f", + "dist/types.js": "6d579404aa50acb42425206a5a0f8211700b3abbae84c656004ecb846c1678c9", + "docs/agent-author.md": "6280a2b6684a83ca7d17871be6666f5d979f5d3076ddbb9680edc83394abb810", + "docs/examples.md": "7bc5fea049219a6d2ddd444dfcfee9b629ca99b22f4bad916086f6e7180ac3df", + "docs/extensions.md": "c6b21c9e7f551fcd2cc3a541627a031fd073abc68c3450855687c871b97f9898", + "docs/factories.md": "f326c8041f2b670f986334165f7ed2bffc607719f6f843aa3c758c76be6467ae", + "docs/factory-patterns.md": "c37bae44a6cbabc4b70a5d0ccde3e04eb15a4c1111b2ed1a053efb1bbd254c33", + "package.json": "9f592e6f722deab687598975e2c857671d60ed35cd3c608c9e19ef58667ad0e0", + "README.md": "1efd95e34cd0b8c5bda83131f025c496872adcfd57e61c45cd918b4ee673c24d" + }, + "after": { + "dist/canvas.d.ts": "2f5c82d47a8a1abd27466f9dc093106a59d83f685d4f6c29367e2ce25f44b700", + "dist/canvas.js": "53046d98b9267cad679a021b0e6e7819134750322b1bc80057622ee95f891991", + "dist/cjs/canvas.js": "c201cf7f92a254998436854cbf8aaa22653d7fe57c915115f70a3377696845dc", + "dist/cjs/client.js": "071c40e7c68f9b92271daa525f9d60bdc06969a463ee455dcafc7f57d4d97d21", + "dist/cjs/cliVersion.js": "e7597d95ea76c0a1193c612192d62fdc4e04aebd792d0183de423eed7c2b1852", + "dist/cjs/copilotRequestHandler.js": "83f881916c15dec351e7c860e7a33d443bb06e14bf753c0c93546e8ce8be3425", + "dist/cjs/extension.js": "9d1190c59e8a6cb9b1f08127c59e413d9dff743dc9163d1e3b8a3aa368318827", + "dist/cjs/extensionLaunchProvider.js": "f4306ba2081a4eceb406b3984b8c2e12a52f3648d8928503d7599b1d69b5b0cb", + "dist/cjs/factory.js": "0080126bf5ce47290eba54f6c42f799d584478ed54075eb684cbff7675768267", + "dist/cjs/ffiRuntimeHost.js": "b0d1c2af9178414c9228383516c0b0341e291d355d584d99040795d42e0535b9", + "dist/cjs/generated/rpc.js": "791462604616f241901903b090af3cb5460d5b3111c798b96085db74f729956d", + "dist/cjs/generated/session-events.js": "0e225f03a52818fd6407984f86b7d42c98d9c8474be25c4926f3b5cb49b5fbfa", + "dist/cjs/index.js": "2fc8ba8f79e1105ceb06e87823711fb2aa2154855586e641d2433e1d430fbd17", + "dist/cjs/package.json": "dbf8353f77358bc12169b7bb7301e1978d5b503e002ee927229a8993672818fc", + "dist/cjs/runtimeArtifacts.js": "2cbaaa4cf9966b8095db2470f0e837c21c94a4924750eb1eda05770031a56124", + "dist/cjs/sdkProtocolVersion.js": "0442311a93414e3285906ce491adc365f16bbb7b289c214f9f32adca4376067c", + "dist/cjs/session.js": "e2db64a1283315d45026dca0f9db714aa64ae43ccc15ff228a2277664dc45213", + "dist/cjs/sessionFsProvider.js": "89dab8bb5a4851ba65d266f5dbbd1b93bad41f92949303de6c1c8b6f6c19703c", + "dist/cjs/telemetry.js": "83180695ac1d8e2c8db7451d9eaef5d78683abf3bb6a4a3e31ea07c26b7557aa", + "dist/cjs/toolSet.js": "e0edbe438f046ed7811062dccafbcef863145dfd6240f6805670ca24524d1fa6", + "dist/cjs/types.js": "f600e6e38b4e34e5527fc509b4286b71f62927b533697beb848ea6e7b7953a69", + "dist/client.d.ts": "14e705058cdf04481ad863b5c9c61b9b65b69423d3ab6de3de93335131432569", + "dist/client.js": "f1a4f6ed65ff9495d51891cb924e2dd3413743475ad1011f2bbc60cc2979e413", + "dist/cliVersion.d.ts": "53c74281b2474d47487e9e9fd04880cacc3549418ea315827941c45ac53b2773", + "dist/cliVersion.js": "0eb84818650f7621cd9da3fd48345337fe33e1b1ac2cdcfffbe1d27aa05c8556", + "dist/copilotRequestHandler.d.ts": "730762ae75ad87be8cbef0080a2f5150919f0a9c53e52b6aaaa3f366f2c17d79", + "dist/copilotRequestHandler.js": "f73b310fab97ccba9282c17cafc579ab545835a72e3b0be79179e95f7221353a", + "dist/extension.d.ts": "a83bc2d74059e6d56331671a33e0d0b6fc12563384e0dd6d279a6ebe45582384", + "dist/extension.js": "4e6da019a4b486d339ee349cb3fad491611c4676b809e82ac861236c35c72c2d", + "dist/extensionLaunchProvider.d.ts": "566bd959e65aa6d971ff0571da5d53a455c570aa834100a666e349f1e89f6371", + "dist/extensionLaunchProvider.js": "f1cea48467399b825b91bf53e06b0a720cbf13b3acde62f7f15f9daf3e133aef", + "dist/factory.d.ts": "f47003bb64004aeb7566a761c8606f7b4416086e03a3fa549797100a93a54d29", + "dist/factory.js": "8e3e436a63f459b111daf0a6727a4751b181c315dcdc79b15def67df3e7cc9cf", + "dist/ffiRuntimeHost.d.ts": "aa7b142d97ac539ac2b7aa1b054d3aa4c297e0617c78e8f4f204662e0f27fa47", + "dist/ffiRuntimeHost.js": "83fa3e587fb964a02c6c3ecb3bb5f5b7082317769515522b8a82b3d943f2cac6", + "dist/generated/rpc.d.ts": "b73efadc291721e1a44a9511fc4000456c934ac14981fdd8276706910e365df7", + "dist/generated/rpc.js": "bda583bb6f65b5814c26aa5ee0e9b7811c3c0c65269a98faab1e7de044e2a9de", + "dist/generated/session-events.d.ts": "1bb19396d3961b1e705df61d8d284f8c45b624e851cdb3faf136dca93b73ea5a", + "dist/generated/session-events.js": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "dist/index.d.ts": "2bfe269d7fed0b79fa731795f689b68f48029e4a2f2fbe2d69a88e3a53b3d115", + "dist/index.js": "8455ad44fe40c1c21c810253b63af70a4722f6a1cec3e7c3db1b18690dc302e0", + "dist/runtimeArtifacts.d.ts": "1d78a2fd37b65e793e553db9a8323a6727b77e7ffbd6b61c462e778794dd76e2", + "dist/runtimeArtifacts.js": "8940ed83b85ee14563a85728cccaac0c5f25c45f3441cce33daf9410297be3cb", + "dist/sdkProtocolVersion.d.ts": "344a1c9669d1617346e2bb71d24081797b69b6cfd37c17b04b2aebf8e7a87260", + "dist/sdkProtocolVersion.js": "4c065062c758839aba7c1f2311886d57acfced0863554b75cc22c275fe9f9fde", + "dist/session.d.ts": "645c3f06dc0206a2963eb6874fd09a0db612a0c00637a0da48cb9462695ad720", + "dist/session.js": "e0bcdd68532f4108906af199531359f422cf192f1e5d61124b82e734ea0f5546", + "dist/sessionFsProvider.d.ts": "6aa732885a832f7117a5d85853eb05b26052bc446a278c17b9463b7bdb06094d", + "dist/sessionFsProvider.js": "703d5c8b64fa805cf303e02fd2ea3bd4df0d649c81983bba0fb08b6c6424c525", + "dist/telemetry.d.ts": "0721ce90ecd3a15eeb86d7787ca62236b1b545b0e93139ed5ef4419b16544722", + "dist/telemetry.js": "dff6ccf631b2508039c41fd69d01cbb344bcb20d83a520e006720d955398ac9f", + "dist/toolSet.d.ts": "41e53ae25809c90fd1a78f493cb912c6a9194c6e0a02e3e32545971c41a1cb56", + "dist/toolSet.js": "8ff3c7953302b99e6c928d715a3a9f21146bd0f544eaa35fb71ebc07227fd7a7", + "dist/types.d.ts": "4cc2cb4037351e929cf871ece44306d1bc1b4a321a590fb57e6638adcdd751e3", + "dist/types.js": "6d579404aa50acb42425206a5a0f8211700b3abbae84c656004ecb846c1678c9", + "docs/agent-author.md": "6280a2b6684a83ca7d17871be6666f5d979f5d3076ddbb9680edc83394abb810", + "docs/examples.md": "7bc5fea049219a6d2ddd444dfcfee9b629ca99b22f4bad916086f6e7180ac3df", + "docs/extensions.md": "c6b21c9e7f551fcd2cc3a541627a031fd073abc68c3450855687c871b97f9898", + "docs/factories.md": "f326c8041f2b670f986334165f7ed2bffc607719f6f843aa3c758c76be6467ae", + "docs/factory-patterns.md": "c37bae44a6cbabc4b70a5d0ccde3e04eb15a4c1111b2ed1a053efb1bbd254c33", + "package.json": "9f592e6f722deab687598975e2c857671d60ed35cd3c608c9e19ef58667ad0e0", + "README.md": "1efd95e34cd0b8c5bda83131f025c496872adcfd57e61c45cd918b4ee673c24d" + }, + "beforeModes": { + "dist/canvas.d.ts": 420, + "dist/canvas.js": 420, + "dist/cjs/canvas.js": 420, + "dist/cjs/client.js": 420, + "dist/cjs/cliVersion.js": 420, + "dist/cjs/copilotRequestHandler.js": 420, + "dist/cjs/extension.js": 420, + "dist/cjs/factory.js": 420, + "dist/cjs/ffiRuntimeHost.js": 420, + "dist/cjs/generated/rpc.js": 420, + "dist/cjs/generated/session-events.js": 420, + "dist/cjs/index.js": 420, + "dist/cjs/package.json": 420, + "dist/cjs/runtimeArtifacts.js": 420, + "dist/cjs/sdkProtocolVersion.js": 420, + "dist/cjs/session.js": 420, + "dist/cjs/sessionFsProvider.js": 420, + "dist/cjs/telemetry.js": 420, + "dist/cjs/toolSet.js": 420, + "dist/cjs/types.js": 420, + "dist/client.d.ts": 420, + "dist/client.js": 420, + "dist/cliVersion.d.ts": 420, + "dist/cliVersion.js": 420, + "dist/copilotRequestHandler.d.ts": 420, + "dist/copilotRequestHandler.js": 420, + "dist/extension.d.ts": 420, + "dist/extension.js": 420, + "dist/factory.d.ts": 420, + "dist/factory.js": 420, + "dist/ffiRuntimeHost.d.ts": 420, + "dist/ffiRuntimeHost.js": 420, + "dist/generated/rpc.d.ts": 420, + "dist/generated/rpc.js": 420, + "dist/generated/session-events.d.ts": 420, + "dist/generated/session-events.js": 420, + "dist/index.d.ts": 420, + "dist/index.js": 420, + "dist/runtimeArtifacts.d.ts": 420, + "dist/runtimeArtifacts.js": 420, + "dist/sdkProtocolVersion.d.ts": 420, + "dist/sdkProtocolVersion.js": 420, + "dist/session.d.ts": 420, + "dist/session.js": 420, + "dist/sessionFsProvider.d.ts": 420, + "dist/sessionFsProvider.js": 420, + "dist/telemetry.d.ts": 420, + "dist/telemetry.js": 420, + "dist/toolSet.d.ts": 420, + "dist/toolSet.js": 420, + "dist/types.d.ts": 420, + "dist/types.js": 420, + "docs/agent-author.md": 420, + "docs/examples.md": 420, + "docs/extensions.md": 420, + "docs/factories.md": 420, + "docs/factory-patterns.md": 420, + "package.json": 420, + "README.md": 420 + }, + "afterModes": { + "dist/canvas.d.ts": 420, + "dist/canvas.js": 420, + "dist/cjs/canvas.js": 420, + "dist/cjs/client.js": 420, + "dist/cjs/cliVersion.js": 420, + "dist/cjs/copilotRequestHandler.js": 420, + "dist/cjs/extension.js": 420, + "dist/cjs/extensionLaunchProvider.js": 420, + "dist/cjs/factory.js": 420, + "dist/cjs/ffiRuntimeHost.js": 420, + "dist/cjs/generated/rpc.js": 420, + "dist/cjs/generated/session-events.js": 420, + "dist/cjs/index.js": 420, + "dist/cjs/package.json": 420, + "dist/cjs/runtimeArtifacts.js": 420, + "dist/cjs/sdkProtocolVersion.js": 420, + "dist/cjs/session.js": 420, + "dist/cjs/sessionFsProvider.js": 420, + "dist/cjs/telemetry.js": 420, + "dist/cjs/toolSet.js": 420, + "dist/cjs/types.js": 420, + "dist/client.d.ts": 420, + "dist/client.js": 420, + "dist/cliVersion.d.ts": 420, + "dist/cliVersion.js": 420, + "dist/copilotRequestHandler.d.ts": 420, + "dist/copilotRequestHandler.js": 420, + "dist/extension.d.ts": 420, + "dist/extension.js": 420, + "dist/extensionLaunchProvider.d.ts": 420, + "dist/extensionLaunchProvider.js": 420, + "dist/factory.d.ts": 420, + "dist/factory.js": 420, + "dist/ffiRuntimeHost.d.ts": 420, + "dist/ffiRuntimeHost.js": 420, + "dist/generated/rpc.d.ts": 420, + "dist/generated/rpc.js": 420, + "dist/generated/session-events.d.ts": 420, + "dist/generated/session-events.js": 420, + "dist/index.d.ts": 420, + "dist/index.js": 420, + "dist/runtimeArtifacts.d.ts": 420, + "dist/runtimeArtifacts.js": 420, + "dist/sdkProtocolVersion.d.ts": 420, + "dist/sdkProtocolVersion.js": 420, + "dist/session.d.ts": 420, + "dist/session.js": 420, + "dist/sessionFsProvider.d.ts": 420, + "dist/sessionFsProvider.js": 420, + "dist/telemetry.d.ts": 420, + "dist/telemetry.js": 420, + "dist/toolSet.d.ts": 420, + "dist/toolSet.js": 420, + "dist/types.d.ts": 420, + "dist/types.js": 420, + "docs/agent-author.md": 420, + "docs/examples.md": 420, + "docs/extensions.md": 420, + "docs/factories.md": 420, + "docs/factory-patterns.md": 420, + "package.json": 420, + "README.md": 420 + }, + "provenance": { + "sourceCommit": "f13e4a2cc7e4e220974d2333142234e162a3252e", + "startupPrerequisiteCommit": "3dbd843e46771f99070221a85d83c85d8046d0bd", + "sourcePatchSha256": "f57c9d4c8d800ed1093c7d9afb41191295bc719b4d11459906e771d5403bb456", + "publishedArchiveSha256": "238147f38bb7597bdd6864445e27137475ec034c831efb555655f3db28d50b3b", + "cliPinUnchanged": "1.0.83", + "schemaBaseline": "1.0.83 plus exact canonical runtime v1 launch/retain fragments", + "candidate": "B3 cancellation lifecycle follow-up to accepted private SDK 1.0.13 B2; accepted R2/B1/B2 remain unchanged; not published availability", + "refinement": "Materialize the owned cancellation tokens before cancellation, make lifetime disposal idempotent/reentrant, and guard overlapping request cancellation. R2 synchronous callback/error semantics, B2 script-safety forwarding, and runtime policy authority are unchanged.", + "b2ManifestSha256": "5475d02aecc64ec2a2f2911de9f8a07842edacfc54eca7a2834367f148b3d55a", + "b2SourceRefinementPatchSha256": "527dad3bf32979564ad552b67365231e90db6e4f1359393ae0b4777b96add5bb", + "b2ChangedEmissions": [ + "dist/cjs/extensionLaunchProvider.js", + "dist/extensionLaunchProvider.js" + ], + "compilerDeclarationsUnchanged": true, + "priorRevisionReleaseEmissionComparison": { + "revision": "B1", + "originalPublishedDistFilesReproducedExactly": 52 + }, + "tools": { + "node": "v24.18.0", + "npm": "11.16.0", + "esbuild": "0.28.1", + "typescript": "5.9.3", + "tsx": "4.22.4", + "vitest": "4.1.8", + "json-schema-to-typescript": "15.0.4" + } + } +} diff --git a/build/npm/copilot-sdk-canvas.md b/build/npm/copilot-sdk-canvas.md new file mode 100644 index 0000000000000..d6cf293b90556 --- /dev/null +++ b/build/npm/copilot-sdk-canvas.md @@ -0,0 +1,124 @@ +# Copilot SDK canvas backport + +VS Code still depends on the published `@github/copilot-sdk@1.0.13`. The adjacent +generated B3 delta supplies the public Node SDK launch-provider, turnless +retention, and initial script-classification bindings needed by the opt-in canvas integration. It is a local +source-backed backport, not a new published SDK or CLI version. + +The SDK's internal CLI pin remains `1.0.83`. Applying this delta does not add +runtime support: the selected runtime must implement launch-provider contract +version 1, `session.retain`, and the initial create/resume script-classification option. The client rejects failed or unsupported +negotiation rather than falling back to unadmitted extension execution. + +## Source and payload + +| File | Purpose | +| --- | --- | +| `copilot-sdk-canvas.source.patch` | Portable Node SDK source changes against the published release's source commit, including its patched generated TypeScript. | +| `copilot-sdk-canvas.build.md` | Portable build recipe, immutable inputs, toolchain and regeneration boundary. | +| `copilot-sdk-canvas.patch` | Generated package-relative changes to ESM, CommonJS and declarations. Do not hand-edit. | +| `copilot-sdk-canvas.json` | Complete before/after package SHA-256 vectors, payload digest, source provenance and build-tool versions. | +| `copilotSdkCanvasPatch.ts` | Version-bound installation and verification of the emitted delta. | + +The base is SDK source commit +`f13e4a2cc7e4e220974d2333142234e162a3252e`. The Node startup single-flight +prerequisite comes from `3dbd843e46771f99070221a85d83c85d8046d0bd`. +The backport adds connection-owned launch-provider attachment, strict v1 +negotiation, cancellation-safe callbacks and global/scoped retention. B2 also +forwards the canonical optional `enableScriptSafety` field in the initial +create and resume requests, before newly loaded extension work can begin. +B3 preserves those bindings and adds idempotent cleanup after startup failure, +without changing the public API or declarations. +It does not transplant the newer SDK's unrelated APIs or dependency changes. + +The unmodified release source reproduces all 52 published `dist` files. +The emitted delta changes 12 paths, including three new files; the complete +package grows from 59 to 62 files. Package metadata, export map, CLI pin, +optional platform dependencies and documentation are unchanged. + +Follow the [portable build recipe](copilot-sdk-canvas.build.md) to build the +patched checked-in TypeScript using the release's locked Node +tooling, not VS Code's TypeScript or esbuild versions. The exact versions and +source-patch digest are recorded in the manifest. Building those checked-in +sources does not require access to a private runtime checkout. + +Regenerating the RPC TypeScript is a separate operation: its schema baseline is +CLI `1.0.83` plus the canonical unreleased launch-v1/retain fragments. Running +the ordinary generator against only the published `1.0.83` schemas would +remove the new bindings. An aligned runtime release and regeneration remain +prerequisites to replacing this backport with a published SDK. + +## Initial script classification + +Use the public `SessionConfig` and `ResumeSessionConfig` types. Explicitly +provide `enableScriptSafety: true` on every create and resume path that needs +read-only shell-command classification, including cold restores and peer chats. +A post-create `options.update` cannot cover extension work that starts earlier. + +The SDK preserves an explicit `false` and omits an undefined value. The +qualified runtime's scalar is not durable: omission on a resident session +preserves its current memory, but cold omission defaults to false even after +retention. This is not a persistence promise. + +The option enables runtime classification of read-only shell commands; those +commands may run without a prompt subject to managed/runtime policy. It does +not approve extension source, grant general tool permissions, override policy, +or sandbox Node. Initial typed forwarding and native initialization ordering +are distinct from demonstrating a real extension model turn before create +returns. + +## Installation + +Normal root postinstall and the cached `fast-install.ts` path both enforce the +delta. The workbench and remote dependency trees are required; an existing +distro remote tree is included. Installation-state hashes include the +carrier, generated payload, manifest and installation scripts. Postinstall +records completion only after required patching succeeds. + +For already installed, real dependency directories: + +```sh +npm run copilot:patch-sdk +npm run copilot:patch-sdk -- --check +``` + +The first command applies or verifies the delta. The second is read-only and +fails if any target is not the complete expected after-image. + +The carrier preflights every target before modifying any package. It rejects +unexpected or partially patched packages, symlinked dependency directories, +unsafe or undeclared patch paths, metadata changes and file removals. Nested +dependencies are preserved separately without following their links. + +Each replacement is prepared in a sibling staging directory. The copied +before-image, complete after-image and original package are checked before +replacement. Git line-ending conversion is disabled for the patch subprocess +so Windows Git settings cannot rewrite the approved package bytes. The user's +Git configuration is not changed. A failed replacement restores the original; +a failed restoration reports and preserves its backup. Cleanup failures are +errors, not successful installation. Replacement is per package, not a +transaction across all trees. +A later repair can finish a mixed complete-before/complete-after installation. + +The checked-in payload applies to the complete published package, not an older +development backport. A known older candidate requires its own reviewed +complete-image transition through the carrier's `manifestPath` option, followed +by verification against this final manifest. Do not apply a clean-release patch +over a previous backport or relax the before-image guard. + +Do not disable the guards to repair a linked or unexpected installation. +Remove dependency links using the mechanism that created them, then restore +real dependencies with `npm ci`. Do not install or patch through a link into +another checkout. Inspect any reported retained backup before removing it. + +These checks bind SDK package bytes; they are not an extension trust decision, +content-bound approval of extension directories, or a Node execution sandbox. + +## Removing the backport + +Move to an aligned published SDK/runtime pair only after its public +launch-provider negotiation, cancellation and turnless-retention behavior are +qualified. Remove the payload, source patch, carrier and repair command +together; remove both installation call sites, their explicit hash inputs and +the generated-patch Git attributes. Do not leave declaration-only shims or a +silent fallback behind. diff --git a/build/npm/copilot-sdk-canvas.patch b/build/npm/copilot-sdk-canvas.patch new file mode 100644 index 0000000000000..47b751a791cab --- /dev/null +++ b/build/npm/copilot-sdk-canvas.patch @@ -0,0 +1,1152 @@ +diff --git a/dist/cjs/client.js b/dist/cjs/client.js +index f559ca2..55c8707 100644 +--- a/dist/cjs/client.js ++++ b/dist/cjs/client.js +@@ -39,6 +39,7 @@ var import_node_path = require("node:path"); + var import_node = require("vscode-jsonrpc/node.js"); + var import_rpc = require("./generated/rpc.js"); + var import_sdkProtocolVersion = require("./sdkProtocolVersion.js"); ++var import_extensionLaunchProvider = require("./extensionLaunchProvider.js"); + var import_session = require("./session.js"); + var import_runtimeArtifacts = require("./runtimeArtifacts.js"); + var import_cliVersion = require("./cliVersion.js"); +@@ -241,6 +242,8 @@ class CopilotClient { + runtimePort = null; + actualHost = "localhost"; + state = "disconnected"; ++ /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */ ++ startPromise = null; + sessions = /* @__PURE__ */ new Map(); + stderrBuffer = ""; + // Captures CLI stderr for error messages +@@ -270,6 +273,8 @@ class CopilotClient { + /** Connection-level session filesystem config, set via constructor option. */ + sessionFsConfig = null; + requestHandler = null; ++ extensionLaunchProvider; ++ extensionLaunchProviderConnection; + builtinPluginDirectories = []; + onGitHubTelemetry; + clientGlobalHandlers = {}; +@@ -279,7 +284,7 @@ class CopilotClient { + * @throws Error if the client is not connected + */ + get rpc() { +- if (!this.connection) { ++ if (!this.connection || this.connectionClosed) { + throw new Error("Client is not connected. Call start() first."); + } + if (!this._rpc) { +@@ -427,6 +432,7 @@ class CopilotClient { + this.onGetTraceContext = options.onGetTraceContext; + this.sessionFsConfig = options.sessionFs ?? null; + this.requestHandler = options.requestHandler ?? null; ++ this.extensionLaunchProvider = options.extensionLaunchProvider; + this.onGitHubTelemetry = options.onGitHubTelemetry; + this.setupClientGlobalHandlers(); + const connEnv = conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : void 0; +@@ -618,6 +624,20 @@ class CopilotClient { + if (this.state === "connected") { + return; + } ++ if (this.startPromise) { ++ return this.startPromise; ++ } ++ this.startPromise = this.doStart(); ++ try { ++ await this.startPromise; ++ } finally { ++ this.startPromise = null; ++ } ++ } ++ async doStart() { ++ if (this.connectionClosed) { ++ await this.forceStop(); ++ } + this.forceStopping = false; + this.connectionClosed = false; + this.processTransportError = null; +@@ -629,6 +649,7 @@ class CopilotClient { + await this.startCLIServer(); + } + await this.connectToServer(); ++ const launchProviderConnection = this.extensionLaunchProviderConnection; + await this.verifyProtocolVersion(); + if (this.builtinPluginDirectories.length > 0) { + try { +@@ -651,6 +672,7 @@ class CopilotClient { + if (this.requestHandler) { + await this.connection.sendRequest("llmInference.setProvider", {}); + } ++ await launchProviderConnection?.register(); + this.state = "connected"; + } catch (error) { + const startupError = this.processTransportError ?? error; +@@ -685,6 +707,7 @@ class CopilotClient { + */ + async stop() { + const errors = []; ++ this.extensionLaunchProviderConnection?.dispose(); + const activeSessions = [...this.sessions.values()]; + if (this.connectionConfig.kind === "inprocess") { + await Promise.allSettled(activeSessions.map((session) => session.abort())); +@@ -824,6 +847,7 @@ class CopilotClient { + this.runtimePort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; ++ this.extensionLaunchProviderConnection = void 0; + return errors; + } + /** +@@ -868,6 +892,7 @@ class CopilotClient { + */ + async forceStop() { + this.forceStopping = true; ++ this.extensionLaunchProviderConnection?.dispose(); + for (const session of this.sessions.values()) { + session._markDisconnected(); + } +@@ -916,6 +941,7 @@ class CopilotClient { + this.runtimePort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; ++ this.extensionLaunchProviderConnection = void 0; + } + /** + * Creates a new conversation session with the Copilot CLI. +@@ -1079,7 +1105,7 @@ class CopilotClient { + if (config.gitHubToken !== void 0 && config.gitHubTokenProvider !== void 0) { + throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); + } +- if (!this.connection) { ++ if (!this.connection || this.startPromise || this.connectionClosed) { + await this.start(); + } + const modeDefaults = this.configDefaultsForMode(); +@@ -1204,6 +1230,7 @@ class CopilotClient { + enableSessionTelemetry: config.enableSessionTelemetry, + enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, ++ enableScriptSafety: config.enableScriptSafety, + sessionLimits: config.sessionLimits, + modelCapabilities: config.modelCapabilities, + largeOutput: toWireLargeOutput(config.largeOutput), +@@ -1329,7 +1356,7 @@ class CopilotClient { + if (config.gitHubToken !== void 0 && config.gitHubTokenProvider !== void 0) { + throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); + } +- if (!this.connection) { ++ if (!this.connection || this.startPromise || this.connectionClosed) { + await this.start(); + } + const session = new import_session.CopilotSession( +@@ -1413,6 +1440,7 @@ class CopilotClient { + excludedBuiltinAgents: config.excludedBuiltinAgents, + enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, ++ enableScriptSafety: config.enableScriptSafety, + sessionLimits: config.sessionLimits, + tools: config.tools?.map((tool) => ({ + name: tool.name, +@@ -2061,8 +2089,13 @@ stderr: ${stderrOutput}` + case "inprocess": + return this.connectViaFfi(); + case "tcp": +- case "uri": + return this.connectViaTcp(); ++ case "uri": { ++ const { host, port } = this.parseCliUrl(this.connectionConfig.url); ++ this.actualHost = host; ++ this.runtimePort = port; ++ return this.connectViaTcp(); ++ } + } + } + /** Starts the in-process FFI runtime with SDK-managed typed options. */ +@@ -2259,15 +2292,28 @@ stderr: ${stderrOutput}` : ""}` + if (!session) throw new Error(`No session found for sessionId: ${sessionId}`); + return session.clientSessionApis; + }); +- (0, import_rpc.registerClientGlobalApiHandlers)(this.connection, this.clientGlobalHandlers); ++ const connection = this.connection; ++ const globalHandlers = { ...this.clientGlobalHandlers }; ++ this._rpc = (0, import_rpc.createServerRpc)(connection); ++ if (this.extensionLaunchProvider) { ++ const provider = new import_extensionLaunchProvider.ExtensionLaunchProviderConnection( ++ this.extensionLaunchProvider, ++ this._rpc.registerExtensionLaunchProvider ++ ); ++ this.extensionLaunchProviderConnection = provider; ++ this._rpc.registerExtensionLaunchProvider = () => provider.register(); ++ globalHandlers.extensionLaunchProvider = provider.handler; ++ } ++ const launchProviderConnection = this.extensionLaunchProviderConnection; ++ (0, import_rpc.registerClientGlobalApiHandlers)(connection, globalHandlers); + this.connection.onRequest( + "hooks.invoke", + async (params) => { + return await this.handleHooksInvoke(params); + } + ); +- const connection = this.connection; + const markDisconnected = () => { ++ launchProviderConnection?.dispose(); + if (this.connection !== connection) { + return; + } +@@ -2280,11 +2326,8 @@ stderr: ${stderrOutput}` : ""}` + this.githubTokenProviders.clear(); + }; + this.connection.onClose(markDisconnected); +- this.connection.onError(() => { +- if (this.connection === connection) { +- this.state = "disconnected"; +- } +- }); ++ this.connection.onDispose(markDisconnected); ++ this.connection.onError(markDisconnected); + } + handleSessionEventNotification(notification) { + if (typeof notification !== "object" || !notification || !("sessionId" in notification) || typeof notification.sessionId !== "string" || !("event" in notification)) { +diff --git a/dist/cjs/extensionLaunchProvider.js b/dist/cjs/extensionLaunchProvider.js +new file mode 100644 +index 0000000..a0fdd1c +--- /dev/null ++++ b/dist/cjs/extensionLaunchProvider.js +@@ -0,0 +1,117 @@ ++"use strict"; ++var __defProp = Object.defineProperty; ++var __getOwnPropDesc = Object.getOwnPropertyDescriptor; ++var __getOwnPropNames = Object.getOwnPropertyNames; ++var __hasOwnProp = Object.prototype.hasOwnProperty; ++var __export = (target, all) => { ++ for (var name in all) ++ __defProp(target, name, { get: all[name], enumerable: true }); ++}; ++var __copyProps = (to, from, except, desc) => { ++ if (from && typeof from === "object" || typeof from === "function") { ++ for (let key of __getOwnPropNames(from)) ++ if (!__hasOwnProp.call(to, key) && key !== except) ++ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); ++ } ++ return to; ++}; ++var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); ++var extensionLaunchProvider_exports = {}; ++__export(extensionLaunchProvider_exports, { ++ ExtensionLaunchProviderConnection: () => ExtensionLaunchProviderConnection ++}); ++module.exports = __toCommonJS(extensionLaunchProvider_exports); ++var import_node = require("vscode-jsonrpc/node.js"); ++function cancelled() { ++ return new import_node.ResponseError(-32800, "Extension launch provider request cancelled"); ++} ++async function withCancellation(run, token) { ++ if (token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ let subscription; ++ const cancellation = new Promise((_, reject) => { ++ subscription = token.onCancellationRequested(() => reject(cancelled())); ++ }); ++ try { ++ const operation = (async () => run())(); ++ const result = await Promise.race([operation, cancellation]); ++ if (token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ return result; ++ } finally { ++ subscription?.dispose(); ++ } ++} ++class ExtensionLaunchProviderConnection { ++ constructor(provider, registerProvider) { ++ this.provider = provider; ++ this.registerProvider = registerProvider; ++ } ++ provider; ++ registerProvider; ++ lifetime = new import_node.CancellationTokenSource(); ++ registration; ++ registered = false; ++ handler = { ++ resolve: (params, token) => this.resolve(params, token) ++ }; ++ async register() { ++ if (this.lifetime.token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ this.registration ??= withCancellation(async () => { ++ const result = await this.registerProvider(); ++ if (result?.contractVersion !== 1) { ++ throw new Error( ++ "Extension launch provider requires contract version 1; the runtime did not acknowledge it." ++ ); ++ } ++ if (this.lifetime.token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ this.registered = true; ++ return result; ++ }, this.lifetime.token); ++ return this.registration; ++ } ++ dispose() { ++ if (this.lifetime.token.isCancellationRequested) { ++ return; ++ } ++ this.lifetime.cancel(); ++ this.lifetime.dispose(); ++ } ++ async resolve(params, token) { ++ if (!this.registered) { ++ throw new Error("Extension launch provider contract has not been acknowledged"); ++ } ++ const request = new import_node.CancellationTokenSource(); ++ const requestToken = request.token; ++ const cancelRequest = () => { ++ if (!requestToken.isCancellationRequested) { ++ request.cancel(); ++ } ++ }; ++ const connectionSubscription = this.lifetime.token.onCancellationRequested(cancelRequest); ++ const requestSubscription = token?.onCancellationRequested(cancelRequest); ++ if (this.lifetime.token.isCancellationRequested || token?.isCancellationRequested) { ++ cancelRequest(); ++ } ++ try { ++ return await withCancellation( ++ () => this.provider.resolve(params, requestToken), ++ requestToken ++ ); ++ } finally { ++ connectionSubscription.dispose(); ++ requestSubscription?.dispose(); ++ request.dispose(); ++ } ++ } ++} ++// Annotate the CommonJS export names for ESM import in node: ++0 && (module.exports = { ++ ExtensionLaunchProviderConnection ++}); +diff --git a/dist/cjs/generated/rpc.js b/dist/cjs/generated/rpc.js +index 7622b8d..56a7226 100644 +--- a/dist/cjs/generated/rpc.js ++++ b/dist/cjs/generated/rpc.js +@@ -211,7 +211,9 @@ function createServerRpc(connection) { + disable: async (params) => connection.sendRequest("extensions.disable", params) + }, + /** +- * Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. ++ * Registers the calling SDK client as the authoritative per-entrypoint extension launch provider and returns the supported contract version. Call before creating any sessions. Contract version 1 supplies sessionId and defaultLaunch when available; absent or null launch, provider errors, timeouts, and shutdown cancellation never fall back. Without a registered provider, legacy launching is unchanged. ++ * ++ * @returns Authoritative capability acknowledgement for the registered extension launch provider. + * + * @experimental + */ +@@ -688,6 +690,15 @@ function createServerRpc(connection) { + * @returns Outcome of an agentRegistry.spawn call. + */ + spawn: async (params) => connection.sendRequest("agentRegistry.spawn", params) ++ }, ++ /** @experimental */ ++ session: { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @param params Identifies the target session. ++ */ ++ retain: async (params) => connection.sendRequest("session.retain", params) + } + }; + } +@@ -770,6 +781,12 @@ function createInternalServerRpc(connection) { + } + function createSessionRpc(connection, sessionId) { + return { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @experimental ++ */ ++ retain: async () => connection.sendRequest("session.retain", { sessionId }), + /** + * Suspends the session while preserving persisted state for later resume. + * +@@ -2906,30 +2923,30 @@ function registerClientSessionApiHandlers(connection, getHandlers) { + }); + } + function registerClientGlobalApiHandlers(connection, handlers) { +- connection.onRequest("extensionLaunchProvider.resolve", async (params) => { ++ connection.onRequest("extensionLaunchProvider.resolve", async (params, token) => { + const handler = handlers.extensionLaunchProvider; + if (!handler) throw new Error("No extensionLaunchProvider client-global handler registered"); +- return handler.resolve(params); ++ return handler.resolve(params, token); + }); +- connection.onRequest("llmInference.httpRequestStart", async (params) => { ++ connection.onRequest("llmInference.httpRequestStart", async (params, token) => { + const handler = handlers.llmInference; + if (!handler) throw new Error("No llmInference client-global handler registered"); +- return handler.httpRequestStart(params); ++ return handler.httpRequestStart(params, token); + }); +- connection.onRequest("llmInference.httpRequestChunk", async (params) => { ++ connection.onRequest("llmInference.httpRequestChunk", async (params, token) => { + const handler = handlers.llmInference; + if (!handler) throw new Error("No llmInference client-global handler registered"); +- return handler.httpRequestChunk(params); ++ return handler.httpRequestChunk(params, token); + }); + connection.onNotification("gitHubTelemetry.event", async (params) => { + const handler = handlers.gitHubTelemetry; + if (!handler) return; + await handler.event(params); + }); +- connection.onRequest("gitHubToken.getToken", async (params) => { ++ connection.onRequest("gitHubToken.getToken", async (params, token) => { + const handler = handlers.gitHubToken; + if (!handler) throw new Error("No gitHubToken client-global handler registered"); +- return handler.getToken(params); ++ return handler.getToken(params, token); + }); + } + // Annotate the CommonJS export names for ESM import in node: +diff --git a/dist/client.d.ts b/dist/client.d.ts +index 1b75a01..f163f76 100644 +--- a/dist/client.d.ts ++++ b/dist/client.d.ts +@@ -12,6 +12,8 @@ export declare class CopilotClient { + private runtimePort; + private actualHost; + private state; ++ /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */ ++ private startPromise; + private sessions; + private stderrBuffer; + /** Resolved connection mode chosen in the constructor. */ +@@ -39,6 +41,8 @@ export declare class CopilotClient { + /** Connection-level session filesystem config, set via constructor option. */ + private sessionFsConfig; + private requestHandler; ++ private extensionLaunchProvider?; ++ private extensionLaunchProviderConnection?; + private builtinPluginDirectories; + private onGitHubTelemetry?; + private clientGlobalHandlers; +@@ -122,6 +126,7 @@ export declare class CopilotClient { + * ``` + */ + start(): Promise; ++ private doStart; + /** + * Stops the CLI server and closes all active sessions. + * +diff --git a/dist/client.js b/dist/client.js +index 35c5f86..cd8ca63 100644 +--- a/dist/client.js ++++ b/dist/client.js +@@ -17,6 +17,7 @@ import { + registerClientSessionApiHandlers + } from "./generated/rpc.js"; + import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; ++import { ExtensionLaunchProviderConnection } from "./extensionLaunchProvider.js"; + import { CopilotSession } from "./session.js"; + import { ensureRuntimeBundle } from "./runtimeArtifacts.js"; + import { COPILOT_CLI_VERSION } from "./cliVersion.js"; +@@ -219,6 +220,8 @@ class CopilotClient { + runtimePort = null; + actualHost = "localhost"; + state = "disconnected"; ++ /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */ ++ startPromise = null; + sessions = /* @__PURE__ */ new Map(); + stderrBuffer = ""; + // Captures CLI stderr for error messages +@@ -248,6 +251,8 @@ class CopilotClient { + /** Connection-level session filesystem config, set via constructor option. */ + sessionFsConfig = null; + requestHandler = null; ++ extensionLaunchProvider; ++ extensionLaunchProviderConnection; + builtinPluginDirectories = []; + onGitHubTelemetry; + clientGlobalHandlers = {}; +@@ -257,7 +262,7 @@ class CopilotClient { + * @throws Error if the client is not connected + */ + get rpc() { +- if (!this.connection) { ++ if (!this.connection || this.connectionClosed) { + throw new Error("Client is not connected. Call start() first."); + } + if (!this._rpc) { +@@ -405,6 +410,7 @@ class CopilotClient { + this.onGetTraceContext = options.onGetTraceContext; + this.sessionFsConfig = options.sessionFs ?? null; + this.requestHandler = options.requestHandler ?? null; ++ this.extensionLaunchProvider = options.extensionLaunchProvider; + this.onGitHubTelemetry = options.onGitHubTelemetry; + this.setupClientGlobalHandlers(); + const connEnv = conn.kind === "stdio" || conn.kind === "tcp" ? conn.env : void 0; +@@ -596,6 +602,20 @@ class CopilotClient { + if (this.state === "connected") { + return; + } ++ if (this.startPromise) { ++ return this.startPromise; ++ } ++ this.startPromise = this.doStart(); ++ try { ++ await this.startPromise; ++ } finally { ++ this.startPromise = null; ++ } ++ } ++ async doStart() { ++ if (this.connectionClosed) { ++ await this.forceStop(); ++ } + this.forceStopping = false; + this.connectionClosed = false; + this.processTransportError = null; +@@ -607,6 +627,7 @@ class CopilotClient { + await this.startCLIServer(); + } + await this.connectToServer(); ++ const launchProviderConnection = this.extensionLaunchProviderConnection; + await this.verifyProtocolVersion(); + if (this.builtinPluginDirectories.length > 0) { + try { +@@ -629,6 +650,7 @@ class CopilotClient { + if (this.requestHandler) { + await this.connection.sendRequest("llmInference.setProvider", {}); + } ++ await launchProviderConnection?.register(); + this.state = "connected"; + } catch (error) { + const startupError = this.processTransportError ?? error; +@@ -663,6 +685,7 @@ class CopilotClient { + */ + async stop() { + const errors = []; ++ this.extensionLaunchProviderConnection?.dispose(); + const activeSessions = [...this.sessions.values()]; + if (this.connectionConfig.kind === "inprocess") { + await Promise.allSettled(activeSessions.map((session) => session.abort())); +@@ -802,6 +825,7 @@ class CopilotClient { + this.runtimePort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; ++ this.extensionLaunchProviderConnection = void 0; + return errors; + } + /** +@@ -846,6 +870,7 @@ class CopilotClient { + */ + async forceStop() { + this.forceStopping = true; ++ this.extensionLaunchProviderConnection?.dispose(); + for (const session of this.sessions.values()) { + session._markDisconnected(); + } +@@ -894,6 +919,7 @@ class CopilotClient { + this.runtimePort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; ++ this.extensionLaunchProviderConnection = void 0; + } + /** + * Creates a new conversation session with the Copilot CLI. +@@ -1057,7 +1083,7 @@ class CopilotClient { + if (config.gitHubToken !== void 0 && config.gitHubTokenProvider !== void 0) { + throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); + } +- if (!this.connection) { ++ if (!this.connection || this.startPromise || this.connectionClosed) { + await this.start(); + } + const modeDefaults = this.configDefaultsForMode(); +@@ -1182,6 +1208,7 @@ class CopilotClient { + enableSessionTelemetry: config.enableSessionTelemetry, + enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, ++ enableScriptSafety: config.enableScriptSafety, + sessionLimits: config.sessionLimits, + modelCapabilities: config.modelCapabilities, + largeOutput: toWireLargeOutput(config.largeOutput), +@@ -1307,7 +1334,7 @@ class CopilotClient { + if (config.gitHubToken !== void 0 && config.gitHubTokenProvider !== void 0) { + throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); + } +- if (!this.connection) { ++ if (!this.connection || this.startPromise || this.connectionClosed) { + await this.start(); + } + const session = new CopilotSession( +@@ -1391,6 +1418,7 @@ class CopilotClient { + excludedBuiltinAgents: config.excludedBuiltinAgents, + enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, ++ enableScriptSafety: config.enableScriptSafety, + sessionLimits: config.sessionLimits, + tools: config.tools?.map((tool) => ({ + name: tool.name, +@@ -2039,8 +2067,13 @@ stderr: ${stderrOutput}` + case "inprocess": + return this.connectViaFfi(); + case "tcp": +- case "uri": + return this.connectViaTcp(); ++ case "uri": { ++ const { host, port } = this.parseCliUrl(this.connectionConfig.url); ++ this.actualHost = host; ++ this.runtimePort = port; ++ return this.connectViaTcp(); ++ } + } + } + /** Starts the in-process FFI runtime with SDK-managed typed options. */ +@@ -2237,15 +2270,28 @@ stderr: ${stderrOutput}` : ""}` + if (!session) throw new Error(`No session found for sessionId: ${sessionId}`); + return session.clientSessionApis; + }); +- registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers); ++ const connection = this.connection; ++ const globalHandlers = { ...this.clientGlobalHandlers }; ++ this._rpc = createServerRpc(connection); ++ if (this.extensionLaunchProvider) { ++ const provider = new ExtensionLaunchProviderConnection( ++ this.extensionLaunchProvider, ++ this._rpc.registerExtensionLaunchProvider ++ ); ++ this.extensionLaunchProviderConnection = provider; ++ this._rpc.registerExtensionLaunchProvider = () => provider.register(); ++ globalHandlers.extensionLaunchProvider = provider.handler; ++ } ++ const launchProviderConnection = this.extensionLaunchProviderConnection; ++ registerClientGlobalApiHandlers(connection, globalHandlers); + this.connection.onRequest( + "hooks.invoke", + async (params) => { + return await this.handleHooksInvoke(params); + } + ); +- const connection = this.connection; + const markDisconnected = () => { ++ launchProviderConnection?.dispose(); + if (this.connection !== connection) { + return; + } +@@ -2258,11 +2304,8 @@ stderr: ${stderrOutput}` : ""}` + this.githubTokenProviders.clear(); + }; + this.connection.onClose(markDisconnected); +- this.connection.onError(() => { +- if (this.connection === connection) { +- this.state = "disconnected"; +- } +- }); ++ this.connection.onDispose(markDisconnected); ++ this.connection.onError(markDisconnected); + } + handleSessionEventNotification(notification) { + if (typeof notification !== "object" || !notification || !("sessionId" in notification) || typeof notification.sessionId !== "string" || !("event" in notification)) { +diff --git a/dist/extensionLaunchProvider.d.ts b/dist/extensionLaunchProvider.d.ts +new file mode 100644 +index 0000000..616f0f0 +--- /dev/null ++++ b/dist/extensionLaunchProvider.d.ts +@@ -0,0 +1,14 @@ ++import type { ExtensionLaunchProviderHandler, ExtensionLaunchProviderRegistrationResult } from "./generated/rpc.js"; ++/** One launch-provider registration and its outstanding requests on a single connection. */ ++export declare class ExtensionLaunchProviderConnection { ++ private readonly provider; ++ private readonly registerProvider; ++ private readonly lifetime; ++ private registration?; ++ private registered; ++ readonly handler: ExtensionLaunchProviderHandler; ++ constructor(provider: ExtensionLaunchProviderHandler, registerProvider: () => Promise); ++ register(): Promise; ++ dispose(): void; ++ private resolve; ++} +diff --git a/dist/extensionLaunchProvider.js b/dist/extensionLaunchProvider.js +new file mode 100644 +index 0000000..0860be5 +--- /dev/null ++++ b/dist/extensionLaunchProvider.js +@@ -0,0 +1,96 @@ ++import { ++ CancellationTokenSource, ++ ResponseError ++} from "vscode-jsonrpc/node.js"; ++function cancelled() { ++ return new ResponseError(-32800, "Extension launch provider request cancelled"); ++} ++async function withCancellation(run, token) { ++ if (token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ let subscription; ++ const cancellation = new Promise((_, reject) => { ++ subscription = token.onCancellationRequested(() => reject(cancelled())); ++ }); ++ try { ++ const operation = (async () => run())(); ++ const result = await Promise.race([operation, cancellation]); ++ if (token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ return result; ++ } finally { ++ subscription?.dispose(); ++ } ++} ++class ExtensionLaunchProviderConnection { ++ constructor(provider, registerProvider) { ++ this.provider = provider; ++ this.registerProvider = registerProvider; ++ } ++ provider; ++ registerProvider; ++ lifetime = new CancellationTokenSource(); ++ registration; ++ registered = false; ++ handler = { ++ resolve: (params, token) => this.resolve(params, token) ++ }; ++ async register() { ++ if (this.lifetime.token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ this.registration ??= withCancellation(async () => { ++ const result = await this.registerProvider(); ++ if (result?.contractVersion !== 1) { ++ throw new Error( ++ "Extension launch provider requires contract version 1; the runtime did not acknowledge it." ++ ); ++ } ++ if (this.lifetime.token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ this.registered = true; ++ return result; ++ }, this.lifetime.token); ++ return this.registration; ++ } ++ dispose() { ++ if (this.lifetime.token.isCancellationRequested) { ++ return; ++ } ++ this.lifetime.cancel(); ++ this.lifetime.dispose(); ++ } ++ async resolve(params, token) { ++ if (!this.registered) { ++ throw new Error("Extension launch provider contract has not been acknowledged"); ++ } ++ const request = new CancellationTokenSource(); ++ const requestToken = request.token; ++ const cancelRequest = () => { ++ if (!requestToken.isCancellationRequested) { ++ request.cancel(); ++ } ++ }; ++ const connectionSubscription = this.lifetime.token.onCancellationRequested(cancelRequest); ++ const requestSubscription = token?.onCancellationRequested(cancelRequest); ++ if (this.lifetime.token.isCancellationRequested || token?.isCancellationRequested) { ++ cancelRequest(); ++ } ++ try { ++ return await withCancellation( ++ () => this.provider.resolve(params, requestToken), ++ requestToken ++ ); ++ } finally { ++ connectionSubscription.dispose(); ++ requestSubscription?.dispose(); ++ request.dispose(); ++ } ++ } ++} ++export { ++ ExtensionLaunchProviderConnection ++}; +diff --git a/dist/generated/rpc.d.ts b/dist/generated/rpc.d.ts +index 8696a7d..93412ee 100644 +--- a/dist/generated/rpc.d.ts ++++ b/dist/generated/rpc.d.ts +@@ -2,7 +2,7 @@ + * AUTO-GENERATED FILE - DO NOT EDIT + * Generated from: api.schema.json + */ +-import type { MessageConnection } from "vscode-jsonrpc/node.js"; ++import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; + import type { AbortReason, AgentModelPolicy, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, RemediationAction, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; + /** A value that can be represented losslessly on the SDK JSON wire. */ + export type JsonValue = null | boolean | number | string | JsonValue[] | { +@@ -7117,16 +7117,24 @@ export interface ExtensionLaunchProviderResolveRequest { + */ + modulePath: string; + source: ExtensionSource; ++ /** ++ * Owning runtime session identifier, when known. ++ */ ++ sessionId?: string; ++ defaultLaunch?: ExtensionLaunchProfile; + } + /** +- * The launch profile for a supported entrypoint. Omit launch when the provider does not support the entrypoint. ++ * The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ExtensionLaunchProviderResolveResult". + */ + /** @experimental */ + export interface ExtensionLaunchProviderResolveResult { +- launch?: ExtensionLaunchProfile; ++ /** ++ * Approved launch profile, or absent/null to deny this candidate without fallback. ++ */ ++ launch?: ExtensionLaunchProfile | null; + } + /** + * Extensions discovered for the session, with their current status. +@@ -22757,6 +22765,32 @@ export interface WorkspacesWriteAutopilotObjectiveResult { + */ + operation: string; + } ++/** ++ * Authoritative capability acknowledgement for the registered extension launch provider. ++ * ++ * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema ++ * via the `definition` "ExtensionLaunchProviderRegistrationResult". ++ */ ++/** @experimental */ ++export interface ExtensionLaunchProviderRegistrationResult { ++ /** ++ * Supported extension launch-provider contract version. Clients requiring this contract must check for version 1 before creating or resuming sessions. ++ */ ++ contractVersion: 1; ++} ++/** ++ * Identifies the target session. ++ * ++ * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema ++ * via the `definition` "SessionRetainRequest". ++ */ ++/** @experimental */ ++export interface SessionRetainRequest { ++ /** ++ * Target session identifier ++ */ ++ sessionId: string; ++} + /** @experimental */ + export interface SessionModelListRequest { + /** +@@ -23066,11 +23100,13 @@ export declare function createServerRpc(connection: MessageConnection): { + disable: (params: DiscoveredExtensionsDisableRequest) => Promise; + }; + /** +- * Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. ++ * Registers the calling SDK client as the authoritative per-entrypoint extension launch provider and returns the supported contract version. Call before creating any sessions. Contract version 1 supplies sessionId and defaultLaunch when available; absent or null launch, provider errors, timeouts, and shutdown cancellation never fall back. Without a registered provider, legacy launching is unchanged. ++ * ++ * @returns Authoritative capability acknowledgement for the registered extension launch provider. + * + * @experimental + */ +- registerExtensionLaunchProvider: () => Promise; ++ registerExtensionLaunchProvider: () => Promise; + /** @experimental */ + catalog: { + /** +@@ -23544,9 +23580,24 @@ export declare function createServerRpc(connection: MessageConnection): { + */ + spawn: (params: AgentRegistrySpawnRequest) => Promise; + }; ++ /** @experimental */ ++ session: { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @param params Identifies the target session. ++ */ ++ retain: (params: SessionRetainRequest) => Promise; ++ }; + }; + /** Create typed session-scoped RPC methods. */ + export declare function createSessionRpc(connection: MessageConnection, sessionId: string): { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @experimental ++ */ ++ retain: () => Promise; + /** + * Suspends the session while preserving persisted state for later resume. + * +@@ -25494,13 +25545,13 @@ export declare function registerClientSessionApiHandlers(connection: MessageConn + /** @experimental */ + export interface ExtensionLaunchProviderHandler { + /** +- * Asks the registered SDK client to resolve an opaque process launch profile for one discovered extension entrypoint immediately before launch or reload. The provider must respond within 15 seconds. ++ * Asks the registered SDK client to approve a launch profile immediately before every extension launch or reload. Return defaultLaunch unchanged to approve the runtime's built-in launcher, or return another profile. An absent or null launch denies execution with no fallback. The provider must respond within 15 seconds. Approval does not sandbox code or freeze mutable files; the host is responsible for approved package contents. + * + * @param params A discovered extension entrypoint that the registered integrator may classify and resolve to an opaque launch profile. + * +- * @returns The launch profile for a supported entrypoint. Omit launch when the provider does not support the entrypoint. ++ * @returns The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher. + */ +- resolve(params: ExtensionLaunchProviderResolveRequest): Promise; ++ resolve(params: ExtensionLaunchProviderResolveRequest, token?: CancellationToken): Promise; + } + /** Handler for `llmInference` client global API methods. */ + /** @experimental */ +@@ -25512,7 +25563,7 @@ export interface LlmInferenceHandler { + * + * @returns Acknowledgement. Returning successfully simply means the SDK accepted the start frame; it does not imply the request will succeed. + */ +- httpRequestStart(params: LlmInferenceHttpRequestStartRequest): Promise; ++ httpRequestStart(params: LlmInferenceHttpRequestStartRequest, token?: CancellationToken): Promise; + /** + * Delivers a body byte range (or a cancellation signal) for a request previously announced via httpRequestStart, correlated by requestId. The runtime fires at least one chunk per request — when there is no body, a single chunk with empty data and end=true. Mid-stream the runtime may send a chunk with cancel=true to abort the request; the SDK then stops issuing httpResponseChunk frames and may emit a terminal httpResponseChunk with error set. + * +@@ -25520,7 +25571,7 @@ export interface LlmInferenceHandler { + * + * @returns Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as fire-and-forget. + */ +- httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest): Promise; ++ httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest, token?: CancellationToken): Promise; + } + /** Handler for `gitHubTelemetry` client global API methods. */ + /** @experimental */ +@@ -25542,7 +25593,7 @@ export interface GitHubTokenHandler { + * + * @returns SDK host response to a GitHub credential request. + */ +- getToken(params: GitHubTokenAcquireRequest): Promise; ++ getToken(params: GitHubTokenAcquireRequest, token?: CancellationToken): Promise; + } + /** All client global API handler groups. */ + export interface ClientGlobalApiHandlers { +diff --git a/dist/generated/rpc.js b/dist/generated/rpc.js +index 92fb384..98ca002 100644 +--- a/dist/generated/rpc.js ++++ b/dist/generated/rpc.js +@@ -183,7 +183,9 @@ function createServerRpc(connection) { + disable: async (params) => connection.sendRequest("extensions.disable", params) + }, + /** +- * Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. ++ * Registers the calling SDK client as the authoritative per-entrypoint extension launch provider and returns the supported contract version. Call before creating any sessions. Contract version 1 supplies sessionId and defaultLaunch when available; absent or null launch, provider errors, timeouts, and shutdown cancellation never fall back. Without a registered provider, legacy launching is unchanged. ++ * ++ * @returns Authoritative capability acknowledgement for the registered extension launch provider. + * + * @experimental + */ +@@ -660,6 +662,15 @@ function createServerRpc(connection) { + * @returns Outcome of an agentRegistry.spawn call. + */ + spawn: async (params) => connection.sendRequest("agentRegistry.spawn", params) ++ }, ++ /** @experimental */ ++ session: { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @param params Identifies the target session. ++ */ ++ retain: async (params) => connection.sendRequest("session.retain", params) + } + }; + } +@@ -742,6 +753,12 @@ function createInternalServerRpc(connection) { + } + function createSessionRpc(connection, sessionId) { + return { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @experimental ++ */ ++ retain: async () => connection.sendRequest("session.retain", { sessionId }), + /** + * Suspends the session while preserving persisted state for later resume. + * +@@ -2878,30 +2895,30 @@ function registerClientSessionApiHandlers(connection, getHandlers) { + }); + } + function registerClientGlobalApiHandlers(connection, handlers) { +- connection.onRequest("extensionLaunchProvider.resolve", async (params) => { ++ connection.onRequest("extensionLaunchProvider.resolve", async (params, token) => { + const handler = handlers.extensionLaunchProvider; + if (!handler) throw new Error("No extensionLaunchProvider client-global handler registered"); +- return handler.resolve(params); ++ return handler.resolve(params, token); + }); +- connection.onRequest("llmInference.httpRequestStart", async (params) => { ++ connection.onRequest("llmInference.httpRequestStart", async (params, token) => { + const handler = handlers.llmInference; + if (!handler) throw new Error("No llmInference client-global handler registered"); +- return handler.httpRequestStart(params); ++ return handler.httpRequestStart(params, token); + }); +- connection.onRequest("llmInference.httpRequestChunk", async (params) => { ++ connection.onRequest("llmInference.httpRequestChunk", async (params, token) => { + const handler = handlers.llmInference; + if (!handler) throw new Error("No llmInference client-global handler registered"); +- return handler.httpRequestChunk(params); ++ return handler.httpRequestChunk(params, token); + }); + connection.onNotification("gitHubTelemetry.event", async (params) => { + const handler = handlers.gitHubTelemetry; + if (!handler) return; + await handler.event(params); + }); +- connection.onRequest("gitHubToken.getToken", async (params) => { ++ connection.onRequest("gitHubToken.getToken", async (params, token) => { + const handler = handlers.gitHubToken; + if (!handler) throw new Error("No gitHubToken client-global handler registered"); +- return handler.getToken(params); ++ return handler.getToken(params, token); + }); + } + export { +diff --git a/dist/generated/session-events.d.ts b/dist/generated/session-events.d.ts +index 104be67..1d703ba 100644 +--- a/dist/generated/session-events.d.ts ++++ b/dist/generated/session-events.d.ts +@@ -9,7 +9,7 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { + /** + * Union of all session event variants emitted by the Copilot CLI runtime. + */ +-export type SessionEvent = StartEvent | ResumeEvent | RemoteSteerableChangedEvent | ErrorEvent | IdleEvent | TitleChangedEvent | ScheduleCreatedEvent | ScheduleCancelledEvent | ScheduleRearmedEvent | AutopilotObjectiveChangedEvent | InfoEvent | WarningEvent | ModelChangeEvent | AutoTierSwitchFailedEvent | ModeChangedEvent | ModeNoticeDeliveredEvent | SessionLimitsChangedEvent | PermissionsChangedEvent | PlanChangedEvent | TodosChangedEvent | WorkspaceFileChangedEvent | HandoffEvent | TruncationEvent | SnapshotRewindEvent | ShutdownEvent | UsageCheckpointEvent | ContextChangedEvent | UsageInfoEvent | ContextClearedEvent | CompactionStartEvent | CompactionCompleteEvent | TaskCompleteEvent | CompletionReceiptEvent | FusionRouteStartedEvent | FusionRouteFailedEvent | FusionResolvedEvent | FusionCompletedEvent | UserMessageEvent | PendingMessagesModifiedEvent | AssistantTurnStartEvent | AssistantIntentEvent | AssistantFusionPhaseStartedEvent | AssistantFusionPhaseActivityEvent | AssistantFusionPhaseCompletedEvent | AssistantFusionPhaseFailedEvent | AssistantServerToolProgressEvent | AssistantReasoningEvent | AssistantReasoningDeltaEvent | AssistantToolCallDeltaEvent | AssistantStreamingDeltaEvent | AssistantMessageEvent | AssistantMessageStartEvent | AssistantMessageDeltaEvent | AssistantTurnEndEvent | AssistantIdleEvent | AssistantUsageEvent | ModelCallFailureEvent | ModelCallFinishedEvent | AbortEvent | ToolUserRequestedEvent | ToolExecutionStartEvent | ToolExecutionPartialResultEvent | ToolExecutionProgressEvent | ToolExecutionCompleteEvent | ToolSearchActivatedEvent | SkillInvokedEvent | SubagentStartedEvent | SubagentConfiguredEvent | SubagentCompletedEvent | SubagentFailedEvent | SubagentSelectedEvent | SubagentDeselectedEvent | HookStartEvent | HookEndEvent | HookProgressEvent | BinaryAssetEvent | SystemMessageEvent | SystemNotificationEvent | PermissionRequestedEvent | PermissionCompletedEvent | UserInputRequestedEvent | UserInputCompletedEvent | ElicitationRequestedEvent | ElicitationCompletedEvent | SamplingRequestedEvent | SamplingCompletedEvent | McpOauthRequiredEvent | McpOauthCompletedEvent | McpHeadersRefreshRequiredEvent | McpHeadersRefreshCompletedEvent | CustomNotificationEvent | UIEphemeralQueryEvent | ExternalToolRequestedEvent | ExternalToolCompletedEvent | CommandQueuedEvent | CommandExecuteEvent | CommandCompletedEvent | AutoModeSwitchRequestedEvent | AutoModeSwitchCompletedEvent | SessionLimitsExhaustedRequestedEvent | SessionLimitsExhaustedCompletedEvent | AutoModeResolvedEvent | ManagedSettingsResolvedEvent | ManagedSettingsEnforcedEvent | CommandsChangedEvent | CapabilitiesChangedEvent | ExitPlanModeRequestedEvent | ExitPlanModeCompletedEvent | ToolsUpdatedEvent | BackgroundTasksChangedEvent | FactoryRunUpdatedEvent | FactoryRunStartedEvent | FactoryRunSettledEvent | SkillsLoadedEvent | CustomAgentsUpdatedEvent | McpServersLoadedEvent | McpServerStatusChangedEvent | McpServerRemovedEvent | McpServerNeedsReconnectEvent | McpToolsListChangedEvent | McpResourcesListChangedEvent | McpPromptsListChangedEvent | ExtensionsLoadedEvent | CanvasOpenedEvent | CanvasRegistryChangedEvent | CanvasClosedEvent | CanvasUnavailableEvent | CanvasRecordedEvent | CanvasRemovedEvent | ExtensionsAttachmentsPushedEvent | McpAppToolCallCompleteEvent; ++export type SessionEvent = StartEvent | ResumeEvent | RemoteSteerableChangedEvent | ErrorEvent | IdleEvent | TitleChangedEvent | ScheduleCreatedEvent | ScheduleCancelledEvent | ScheduleRearmedEvent | AutopilotObjectiveChangedEvent | RetainedEvent | InfoEvent | WarningEvent | ModelChangeEvent | AutoTierSwitchFailedEvent | ModeChangedEvent | ModeNoticeDeliveredEvent | SessionLimitsChangedEvent | PermissionsChangedEvent | PlanChangedEvent | TodosChangedEvent | WorkspaceFileChangedEvent | HandoffEvent | TruncationEvent | SnapshotRewindEvent | ShutdownEvent | UsageCheckpointEvent | ContextChangedEvent | UsageInfoEvent | ContextClearedEvent | CompactionStartEvent | CompactionCompleteEvent | TaskCompleteEvent | CompletionReceiptEvent | FusionRouteStartedEvent | FusionRouteFailedEvent | FusionResolvedEvent | FusionCompletedEvent | UserMessageEvent | PendingMessagesModifiedEvent | AssistantTurnStartEvent | AssistantIntentEvent | AssistantFusionPhaseStartedEvent | AssistantFusionPhaseActivityEvent | AssistantFusionPhaseCompletedEvent | AssistantFusionPhaseFailedEvent | AssistantServerToolProgressEvent | AssistantReasoningEvent | AssistantReasoningDeltaEvent | AssistantToolCallDeltaEvent | AssistantStreamingDeltaEvent | AssistantMessageEvent | AssistantMessageStartEvent | AssistantMessageDeltaEvent | AssistantTurnEndEvent | AssistantIdleEvent | AssistantUsageEvent | ModelCallFailureEvent | ModelCallFinishedEvent | AbortEvent | ToolUserRequestedEvent | ToolExecutionStartEvent | ToolExecutionPartialResultEvent | ToolExecutionProgressEvent | ToolExecutionCompleteEvent | ToolSearchActivatedEvent | SkillInvokedEvent | SubagentStartedEvent | SubagentConfiguredEvent | SubagentCompletedEvent | SubagentFailedEvent | SubagentSelectedEvent | SubagentDeselectedEvent | HookStartEvent | HookEndEvent | HookProgressEvent | BinaryAssetEvent | SystemMessageEvent | SystemNotificationEvent | PermissionRequestedEvent | PermissionCompletedEvent | UserInputRequestedEvent | UserInputCompletedEvent | ElicitationRequestedEvent | ElicitationCompletedEvent | SamplingRequestedEvent | SamplingCompletedEvent | McpOauthRequiredEvent | McpOauthCompletedEvent | McpHeadersRefreshRequiredEvent | McpHeadersRefreshCompletedEvent | CustomNotificationEvent | UIEphemeralQueryEvent | ExternalToolRequestedEvent | ExternalToolCompletedEvent | CommandQueuedEvent | CommandExecuteEvent | CommandCompletedEvent | AutoModeSwitchRequestedEvent | AutoModeSwitchCompletedEvent | SessionLimitsExhaustedRequestedEvent | SessionLimitsExhaustedCompletedEvent | AutoModeResolvedEvent | ManagedSettingsResolvedEvent | ManagedSettingsEnforcedEvent | CommandsChangedEvent | CapabilitiesChangedEvent | ExitPlanModeRequestedEvent | ExitPlanModeCompletedEvent | ToolsUpdatedEvent | BackgroundTasksChangedEvent | FactoryRunUpdatedEvent | FactoryRunStartedEvent | FactoryRunSettledEvent | SkillsLoadedEvent | CustomAgentsUpdatedEvent | McpServersLoadedEvent | McpServerStatusChangedEvent | McpServerRemovedEvent | McpServerNeedsReconnectEvent | McpToolsListChangedEvent | McpResourcesListChangedEvent | McpPromptsListChangedEvent | ExtensionsLoadedEvent | CanvasOpenedEvent | CanvasRegistryChangedEvent | CanvasClosedEvent | CanvasUnavailableEvent | CanvasRecordedEvent | CanvasRemovedEvent | ExtensionsAttachmentsPushedEvent | McpAppToolCallCompleteEvent; + /** + * Routing preference used when the session model is `auto`. + */ +@@ -1564,6 +1564,43 @@ export interface AutopilotObjectiveChangedData { + operation: AutopilotObjectiveChangedOperation; + status?: AutopilotObjectiveChangedStatus; + } ++/** ++ * Session event "session.retained". Explicit host intent to persist this local session independently of conversation turns. Emitted by session.retain before a potentially effectful non-chat operation; not a user or assistant message. ++ */ ++/** @experimental */ ++export interface RetainedEvent { ++ /** ++ * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. ++ */ ++ agentId?: string; ++ data: RetainedData; ++ /** ++ * When true, the event is transient and not persisted to the session event log on disk ++ */ ++ ephemeral?: boolean; ++ /** ++ * Unique event identifier (UUID v4), generated when the event is emitted ++ */ ++ id: string; ++ /** ++ * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. ++ */ ++ parentId: string | null; ++ /** ++ * ISO 8601 timestamp when the event was created ++ */ ++ timestamp: string; ++ /** ++ * Type discriminator. Always "session.retained". ++ */ ++ type: "session.retained"; ++} ++/** ++ * Explicit host intent to persist this local session independently of conversation turns. Emitted by session.retain before a potentially effectful non-chat operation; not a user or assistant message. ++ */ ++/** @experimental */ ++export interface RetainedData { ++} + /** + * Session event "session.info". Informational message for timeline display with categorization + */ +diff --git a/dist/index.d.ts b/dist/index.d.ts +index f480b4d..9fbbcda 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -11,5 +11,5 @@ export { defineFactory, FactoryResumeError, isFactoryRunTerminal } from "./facto + export { Canvas, CanvasError, createCanvas, type CanvasAction, type CanvasDeclaration, type CanvasHostContext, type CanvasHostContextCapabilities, type CanvasJsonSchema, type CanvasOptions, } from "./canvas.js"; + export { defineTool, approveAll, createAttributedPermissionResult, convertMcpCallToolResult, createSessionFsAdapter, CopilotRequestHandler, CopilotWebSocketHandler, CopilotWebSocketCloseStatus, CopilotWebSocketForwarder, SessionFsSqliteTransactionFailure, SYSTEM_MESSAGE_SECTIONS, } from "./types.js"; + export type * from "./generated/session-events.js"; +-export type { AskUserVariant, CommandContext, CommandDefinition, CommandHandler, CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, AgentStopHandler, AgentStopHookInput, AgentStopHookOutput, UserPromptTransformedHandler, UserPromptTransformedHookInput, UserPromptTransformedHookOutput, CopilotClientInfo, CopilotClientMode, CopilotClientOptions, CopilotExpAssignmentResponse, StdioRuntimeConnection, InProcessRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, ChildProcessRuntimeConnection, CustomAgentConfig, ElicitationFieldValue, ElicitationHandler, ElicitationParams, ElicitationContext, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExpConfigEntry, ExpFlagValue, ExitPlanModeHandler, ExitPlanModeRequest, ExitPlanModeResult, ExtensionInfo, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, GitHubMcpToolConfig, GitHubTelemetryNotification, GitHubTelemetryEvent, GitHubTelemetryClientInfo, GitHubTokenAcquireReason, GitHubTokenAcquireResult, GitHubTokenProvider, GitHubTokenProviderArgs, GitHubTokenProviderResult, InfiniteSessionConfig, LargeToolOutputConfig, MemoryConfiguration, UiInputOptions, FactoryLimits, FactoryMeta, MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, DefaultAgentConfig, BearerTokenProvider, MessageOptions, ManagedSettings, ManagedSettingsPermissions, ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, AutoTier, CapiSessionOptions, CurrentModel, ModelSwitchAutoTierResult, ModelSwitchAutoTierStatus, ModelCapabilities, ModelCapabilitiesOverride, ModelInfo, ModelPolicy, NamedProviderConfig, PermissionHandler, PermissionRequest, PermissionRequestedData, PermissionRequestedEvent, PermissionRequestResult, AttributedPermissionResult, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource, PermissionDecisionSurface, PermissionResponseCapability, ProviderConfig, ProviderModelConfig, ProviderTokenArgs, RemoteSessionMode, ResumeSessionConfig, SectionOverride, SectionOverrideAction, SectionTransformFn, SessionCapabilities, SessionConfig, SessionConfigBase, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, SessionLifecycleEvent, SessionLifecycleEventMetadata, SessionLifecycleEventType, SessionLifecycleHandler, SessionHooks, SessionCreatedEvent, SessionDeletedEvent, SessionUpdatedEvent, SessionForegroundEvent, SessionBackgroundEvent, SessionContext, SessionListFilter, SessionMetadata, SessionUiApi, SessionFsConfig, SessionFsProvider, SessionFsFileInfo, SessionFsSqliteQueryResult, SessionFsSqliteQueryType, SessionFsSqliteProvider, SessionFsSqliteStatement, SessionFsSqliteTransactionErrorClass, CopilotRequestContext, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, SystemMessageReplaceConfig, SystemMessageSection, TelemetryConfig, TraceContext, TraceContextProvider, Tool, ToolHandler, ToolInvocation, CurrentToolMetadata, ToolTelemetry, ToolResultObject, ToolSearchConfig, TypedSessionEventHandler, TypedSessionLifecycleHandler, ZodSchema, } from "./types.js"; ++export type { AskUserVariant, CommandContext, CommandDefinition, CommandHandler, CanvasProviderIdentity, CloudSessionOptions, CloudSessionRepository, AutoModeSwitchHandler, AutoModeSwitchRequest, AutoModeSwitchResponse, AgentStopHandler, AgentStopHookInput, AgentStopHookOutput, UserPromptTransformedHandler, UserPromptTransformedHookInput, UserPromptTransformedHookOutput, CopilotClientInfo, CopilotClientMode, CopilotClientOptions, CopilotExpAssignmentResponse, StdioRuntimeConnection, InProcessRuntimeConnection, TcpRuntimeConnection, UriRuntimeConnection, ChildProcessRuntimeConnection, CustomAgentConfig, ElicitationFieldValue, ElicitationHandler, ElicitationParams, ElicitationContext, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExpConfigEntry, ExpFlagValue, ExitPlanModeHandler, ExitPlanModeRequest, ExitPlanModeResult, ExtensionInfo, ExtensionLaunchProfile, ExtensionLaunchProviderHandler, ExtensionLaunchProviderRegistrationResult, ExtensionLaunchProviderResolveRequest, ExtensionLaunchProviderResolveResult, ExtensionSource, ForegroundSessionInfo, GetAuthStatusResponse, GetStatusResponse, GitHubMcpToolConfig, GitHubTelemetryNotification, GitHubTelemetryEvent, GitHubTelemetryClientInfo, GitHubTokenAcquireReason, GitHubTokenAcquireResult, GitHubTokenProvider, GitHubTokenProviderArgs, GitHubTokenProviderResult, InfiniteSessionConfig, LargeToolOutputConfig, MemoryConfiguration, UiInputOptions, FactoryLimits, FactoryMeta, MCPStdioServerConfig, MCPHTTPServerConfig, MCPServerConfig, DefaultAgentConfig, BearerTokenProvider, MessageOptions, ManagedSettings, ManagedSettingsPermissions, ModelBilling, ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, AutoTier, CapiSessionOptions, CurrentModel, ModelSwitchAutoTierResult, ModelSwitchAutoTierStatus, ModelCapabilities, ModelCapabilitiesOverride, ModelInfo, ModelPolicy, NamedProviderConfig, PermissionHandler, PermissionRequest, PermissionRequestedData, PermissionRequestedEvent, PermissionRequestResult, AttributedPermissionResult, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionSource, PermissionDecisionSurface, PermissionResponseCapability, ProviderConfig, ProviderModelConfig, ProviderTokenArgs, RemoteSessionMode, ResumeSessionConfig, SectionOverride, SectionOverrideAction, SectionTransformFn, SessionCapabilities, SessionConfig, SessionConfigBase, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, SessionLifecycleEvent, SessionLifecycleEventMetadata, SessionLifecycleEventType, SessionLifecycleHandler, SessionHooks, SessionCreatedEvent, SessionDeletedEvent, SessionUpdatedEvent, SessionForegroundEvent, SessionBackgroundEvent, SessionContext, SessionListFilter, SessionMetadata, SessionRetainRequest, SessionUiApi, SessionFsConfig, SessionFsProvider, SessionFsFileInfo, SessionFsSqliteQueryResult, SessionFsSqliteQueryType, SessionFsSqliteProvider, SessionFsSqliteStatement, SessionFsSqliteTransactionErrorClass, CopilotRequestContext, SystemMessageAppendConfig, SystemMessageConfig, SystemMessageCustomizeConfig, SystemMessageReplaceConfig, SystemMessageSection, TelemetryConfig, TraceContext, TraceContextProvider, Tool, ToolHandler, ToolInvocation, CurrentToolMetadata, ToolTelemetry, ToolResultObject, ToolSearchConfig, TypedSessionEventHandler, TypedSessionLifecycleHandler, ZodSchema, } from "./types.js"; + export type { RunOptions, ResumeOptions, FactoryResumeErrorCode, SessionFactoryApi, FactoryAgentOptions, FactoryContext, FactoryDefinition, FactoryHandle, FactoryJsonSchema, JsonValue, FactoryPipelineStage, FactoryStepOptions, FactoryRunResult, FactoryRunStatus, FactoryRunSummary, FactoryListRunsOptions, FactoryRunsPage, FactoryRunDetail, FactoryProgressPage, FactoryProgressLine, FactoryPhaseObservation, FactoryPhaseStatus, FactoryAgentSummary, } from "./factory.js"; +diff --git a/dist/types.d.ts b/dist/types.d.ts +index 006c541..3f1d81e 100644 +--- a/dist/types.d.ts ++++ b/dist/types.d.ts +@@ -7,10 +7,11 @@ import type { CopilotRequestHandler } from "./copilotRequestHandler.js"; + import type { AutoTier, PermissionRequest as GeneratedPermissionRequest, PermissionRequestedData as GeneratedPermissionRequestedData, PermissionRequestedEvent as GeneratedPermissionRequestedEvent, ReasoningSummary, SessionLimitsConfig, SessionEvent as GeneratedSessionEvent } from "./generated/session-events.js"; + import type { CopilotSession } from "./session.js"; + import type { FactoryJsonSchema, JsonValue } from "./factory.js"; +-import type { GitHubTokenAcquireRequest, GitHubTokenAcquireResult, GitHubTelemetryNotification, ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, CurrentToolMetadata } from "./generated/rpc.js"; ++import type { ExtensionLaunchProviderHandler, GitHubTokenAcquireRequest, GitHubTokenAcquireResult, GitHubTelemetryNotification, ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, CurrentToolMetadata, SessionOpenOptions } from "./generated/rpc.js"; + import type { ToolSet } from "./toolSet.js"; + export type { RemoteSessionMode } from "./generated/rpc.js"; + export type { CurrentToolMetadata } from "./generated/rpc.js"; ++export type { ExtensionLaunchProfile, ExtensionLaunchProviderHandler, ExtensionLaunchProviderRegistrationResult, ExtensionLaunchProviderResolveRequest, ExtensionLaunchProviderResolveResult, ExtensionSource, SessionRetainRequest, } from "./generated/rpc.js"; + export type { GitHubTokenAcquireReason, GitHubTokenAcquireResult, GitHubTelemetryNotification, GitHubTelemetryEvent, GitHubTelemetryClientInfo, } from "./generated/rpc.js"; + /** + * Arguments passed to a session's {@link GitHubTokenProvider}. +@@ -371,6 +372,29 @@ export interface CopilotClientOptions { + * @experimental + */ + requestHandler?: CopilotRequestHandler; ++ /** ++ * Connection-owned extension launch admission handler. ++ * ++ * Attached before the RPC handshake. `start()` registers it and requires an explicit ++ * contract-version-1 acknowledgement before create/resume can proceed. ++ * Missing support, invalid acknowledgements, and registration errors reject ++ * startup; they never opt back into the runtime's legacy launcher. ++ * ++ * Each resolve receives the original source identity and optional runtime ++ * session/default-launch context. Return `defaultLaunch` unchanged only ++ * after approving the source and completing any required retention through ++ * `client.rpc.session.retain({ sessionId })`. An absent or null launch denies ++ * execution. The optional cancellation token is cancelled on request ++ * cancellation, disconnect, or stop; late results are not reused. ++ * ++ * Reconnecting negotiates a new registration and resolves each launch anew. ++ * A runtime that keeps a disconnected provider authoritative may refuse ++ * replacement; that error is propagated rather than bypassing the old owner. ++ * Omitting this option preserves legacy runtime extension behavior. ++ * ++ * @experimental ++ */ ++ extensionLaunchProvider?: ExtensionLaunchProviderHandler; + /** + * Experimental. Receives GitHub telemetry events the runtime forwards to + * this connection. When set, the client opts each session it creates or +@@ -2087,6 +2111,17 @@ export interface SessionConfigBase { + * reconstruct changes from earlier untracked turns. + */ + enableFileChangeTracking?: boolean; ++ /** ++ * Enables read-only classification of built-in shell commands. When true, ++ * commands classified as read-only may run without a permission prompt, ++ * subject to runtime policy. This is not an extension sandbox. ++ * ++ * Applied during session creation or resume, before new extension ++ * initialization. Omission preserves the runtime's existing behavior. ++ * ++ * @experimental ++ */ ++ enableScriptSafety?: SessionOpenOptions["enableScriptSafety"]; + /** + * Limits applied to this session's current accounting window. + * diff --git a/build/npm/copilot-sdk-canvas.source.patch b/build/npm/copilot-sdk-canvas.source.patch new file mode 100644 index 0000000000000..0157e5a63b1c4 --- /dev/null +++ b/build/npm/copilot-sdk-canvas.source.patch @@ -0,0 +1,1840 @@ +diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts +index eb92cf0..c218bdc 100644 +--- a/nodejs/src/client.ts ++++ b/nodejs/src/client.ts +@@ -33,6 +33,7 @@ import { + } from "./generated/rpc.js"; + import type { + ConnectClientInfo, ++ ExtensionLaunchProviderHandler, + GitHubTelemetryNotification, + GitHubTokenAcquireRequest, + GitHubTokenAcquireResult, +@@ -41,6 +42,7 @@ import type { + TaskKind, + } from "./generated/rpc.js"; + import { getSdkProtocolVersion } from "./sdkProtocolVersion.js"; ++import { ExtensionLaunchProviderConnection } from "./extensionLaunchProvider.js"; + import { CopilotSession } from "./session.js"; + import type { FfiRuntimeHost } from "./ffiRuntimeHost.js"; + import { ensureRuntimeBundle } from "./runtimeArtifacts.js"; +@@ -448,6 +450,8 @@ export class CopilotClient { + private runtimePort: number | null = null; + private actualHost: string = "localhost"; + private state: "disconnected" | "connecting" | "connected" | "error" = "disconnected"; ++ /** Shared in-flight start; concurrent callers await it instead of spawning another CLI. */ ++ private startPromise: Promise | null = null; + private sessions: Map = new Map(); + private stderrBuffer: string = ""; // Captures CLI stderr for error messages + /** Resolved connection mode chosen in the constructor. */ +@@ -489,6 +493,8 @@ export class CopilotClient { + /** Connection-level session filesystem config, set via constructor option. */ + private sessionFsConfig: SessionFsConfig | null = null; + private requestHandler: CopilotRequestHandler | null = null; ++ private extensionLaunchProvider?: ExtensionLaunchProviderHandler; ++ private extensionLaunchProviderConnection?: ExtensionLaunchProviderConnection; + private builtinPluginDirectories: string[] = []; + private onGitHubTelemetry?: (notification: GitHubTelemetryNotification) => void | Promise; + private clientGlobalHandlers: import("./generated/rpc.js").ClientGlobalApiHandlers = {}; +@@ -502,7 +508,7 @@ export class CopilotClient { + * @throws Error if the client is not connected + */ + get rpc(): ReturnType { +- if (!this.connection) { ++ if (!this.connection || this.connectionClosed) { + throw new Error("Client is not connected. Call start() first."); + } + if (!this._rpc) { +@@ -687,6 +693,7 @@ export class CopilotClient { + this.onGetTraceContext = options.onGetTraceContext; + this.sessionFsConfig = options.sessionFs ?? null; + this.requestHandler = options.requestHandler ?? null; ++ this.extensionLaunchProvider = options.extensionLaunchProvider; + this.onGitHubTelemetry = options.onGitHubTelemetry; + this.setupClientGlobalHandlers(); + +@@ -935,6 +942,23 @@ export class CopilotClient { + return; + } + ++ // Concurrent callers share one in-progress start instead of each spawning a CLI. ++ if (this.startPromise) { ++ return this.startPromise; ++ } ++ ++ this.startPromise = this.doStart(); ++ try { ++ await this.startPromise; ++ } finally { ++ this.startPromise = null; ++ } ++ } ++ ++ private async doStart(): Promise { ++ if (this.connectionClosed) { ++ await this.forceStop(); ++ } + this.forceStopping = false; + this.connectionClosed = false; + this.processTransportError = null; +@@ -950,6 +974,7 @@ export class CopilotClient { + + // Connect to the server + await this.connectToServer(); ++ const launchProviderConnection = this.extensionLaunchProviderConnection; + + // Verify protocol version compatibility + await this.verifyProtocolVersion(); +@@ -982,6 +1007,7 @@ export class CopilotClient { + await this.connection!.sendRequest("llmInference.setProvider", {}); + } + ++ await launchProviderConnection?.register(); + this.state = "connected"; + } catch (error) { + const startupError = this.processTransportError ?? error; +@@ -1017,6 +1043,7 @@ export class CopilotClient { + */ + async stop(): Promise { + const errors: Error[] = []; ++ this.extensionLaunchProviderConnection?.dispose(); + + // Disconnect all active sessions with retry logic + const activeSessions = [...this.sessions.values()]; +@@ -1202,6 +1229,7 @@ export class CopilotClient { + this.runtimePort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; ++ this.extensionLaunchProviderConnection = undefined; + + return errors; + } +@@ -1249,6 +1277,7 @@ export class CopilotClient { + */ + async forceStop(): Promise { + this.forceStopping = true; ++ this.extensionLaunchProviderConnection?.dispose(); + + // Clear sessions immediately without trying to destroy them + for (const session of this.sessions.values()) { +@@ -1315,6 +1344,7 @@ export class CopilotClient { + this.runtimePort = null; + this.stderrBuffer = ""; + this.processExitPromise = null; ++ this.extensionLaunchProviderConnection = undefined; + } + + /** +@@ -1510,7 +1540,7 @@ export class CopilotClient { + if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) { + throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); + } +- if (!this.connection) { ++ if (!this.connection || this.startPromise || this.connectionClosed) { + await this.start(); + } + +@@ -1666,6 +1696,7 @@ export class CopilotClient { + enableSessionTelemetry: config.enableSessionTelemetry, + enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, ++ enableScriptSafety: config.enableScriptSafety, + sessionLimits: config.sessionLimits, + modelCapabilities: config.modelCapabilities, + largeOutput: toWireLargeOutput(config.largeOutput), +@@ -1817,7 +1848,7 @@ export class CopilotClient { + if (config.gitHubToken !== undefined && config.gitHubTokenProvider !== undefined) { + throw new Error("gitHubToken and gitHubTokenProvider are mutually exclusive"); + } +- if (!this.connection) { ++ if (!this.connection || this.startPromise || this.connectionClosed) { + await this.start(); + } + +@@ -1910,6 +1941,7 @@ export class CopilotClient { + excludedBuiltinAgents: config.excludedBuiltinAgents, + enableCitations: config.enableCitations, + enableFileChangeTracking: config.enableFileChangeTracking, ++ enableScriptSafety: config.enableScriptSafety, + sessionLimits: config.sessionLimits, + tools: config.tools?.map((tool) => ({ + name: tool.name, +@@ -2787,8 +2819,13 @@ export class CopilotClient { + case "inprocess": + return this.connectViaFfi(); + case "tcp": +- case "uri": + return this.connectViaTcp(); ++ case "uri": { ++ const { host, port } = this.parseCliUrl(this.connectionConfig.url); ++ this.actualHost = host; ++ this.runtimePort = port; ++ return this.connectViaTcp(); ++ } + } + } + +@@ -3048,7 +3085,20 @@ export class CopilotClient { + // Register client *global* API handlers (e.g. LLM inference) on the + // same connection. These methods carry no implicit sessionId dispatch + // — the runtime calls into a single handler for the whole connection. +- registerClientGlobalApiHandlers(this.connection, this.clientGlobalHandlers); ++ const connection = this.connection; ++ const globalHandlers = { ...this.clientGlobalHandlers }; ++ this._rpc = createServerRpc(connection); ++ if (this.extensionLaunchProvider) { ++ const provider = new ExtensionLaunchProviderConnection( ++ this.extensionLaunchProvider, ++ this._rpc.registerExtensionLaunchProvider ++ ); ++ this.extensionLaunchProviderConnection = provider; ++ this._rpc.registerExtensionLaunchProvider = () => provider.register(); ++ globalHandlers.extensionLaunchProvider = provider.handler; ++ } ++ const launchProviderConnection = this.extensionLaunchProviderConnection; ++ registerClientGlobalApiHandlers(connection, globalHandlers); + + // `hooks.invoke` is an internal RPC method: the runtime calls it to + // invoke a hook callback on the client. Route each call to the matching +@@ -3061,8 +3111,8 @@ export class CopilotClient { + } + ); + +- const connection = this.connection; + const markDisconnected = () => { ++ launchProviderConnection?.dispose(); + if (this.connection !== connection) { + return; + } +@@ -3075,11 +3125,8 @@ export class CopilotClient { + this.githubTokenProviders.clear(); + }; + this.connection.onClose(markDisconnected); +- this.connection.onError(() => { +- if (this.connection === connection) { +- this.state = "disconnected"; +- } +- }); ++ this.connection.onDispose(markDisconnected); ++ this.connection.onError(markDisconnected); + } + + private handleSessionEventNotification(notification: unknown): void { +diff --git a/nodejs/src/extensionLaunchProvider.ts b/nodejs/src/extensionLaunchProvider.ts +new file mode 100644 +index 0000000..f434f7c +--- /dev/null ++++ b/nodejs/src/extensionLaunchProvider.ts +@@ -0,0 +1,117 @@ ++/*--------------------------------------------------------------------------------------------- ++ * Copyright (c) Microsoft Corporation. All rights reserved. ++ *--------------------------------------------------------------------------------------------*/ ++ ++import { ++ CancellationTokenSource, ++ ResponseError, ++ type CancellationToken, ++ type Disposable, ++} from "vscode-jsonrpc/node.js"; ++import type { ++ ExtensionLaunchProviderHandler, ++ ExtensionLaunchProviderRegistrationResult, ++ ExtensionLaunchProviderResolveRequest, ++ ExtensionLaunchProviderResolveResult, ++} from "./generated/rpc.js"; ++ ++function cancelled(): ResponseError { ++ return new ResponseError(-32800, "Extension launch provider request cancelled"); ++} ++ ++async function withCancellation(run: () => Promise, token: CancellationToken): Promise { ++ if (token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ let subscription: Disposable | undefined; ++ const cancellation = new Promise((_, reject) => { ++ subscription = token.onCancellationRequested(() => reject(cancelled())); ++ }); ++ try { ++ // Invoke synchronously, but turn throws into promises before observing both race inputs. ++ const operation = (async () => run())(); ++ const result = await Promise.race([operation, cancellation]); ++ if (token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ return result; ++ } finally { ++ subscription?.dispose(); ++ } ++} ++ ++/** One launch-provider registration and its outstanding requests on a single connection. */ ++export class ExtensionLaunchProviderConnection { ++ private readonly lifetime = new CancellationTokenSource(); ++ private registration?: Promise; ++ private registered = false; ++ ++ readonly handler: ExtensionLaunchProviderHandler = { ++ resolve: (params, token) => this.resolve(params, token), ++ }; ++ ++ constructor( ++ private readonly provider: ExtensionLaunchProviderHandler, ++ private readonly registerProvider: () => Promise ++ ) {} ++ ++ async register(): Promise { ++ if (this.lifetime.token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ this.registration ??= withCancellation(async () => { ++ const result = await this.registerProvider(); ++ if (result?.contractVersion !== 1) { ++ throw new Error( ++ "Extension launch provider requires contract version 1; the runtime did not acknowledge it." ++ ); ++ } ++ if (this.lifetime.token.isCancellationRequested) { ++ throw cancelled(); ++ } ++ this.registered = true; ++ return result; ++ }, this.lifetime.token); ++ return this.registration; ++ } ++ ++ dispose(): void { ++ // Materialize the lazy token before cancelling, and make teardown reentrant. ++ if (this.lifetime.token.isCancellationRequested) { ++ return; ++ } ++ this.lifetime.cancel(); ++ this.lifetime.dispose(); ++ } ++ ++ private async resolve( ++ params: ExtensionLaunchProviderResolveRequest, ++ token?: CancellationToken ++ ): Promise { ++ if (!this.registered) { ++ throw new Error("Extension launch provider contract has not been acknowledged"); ++ } ++ const request = new CancellationTokenSource(); ++ const requestToken = request.token; ++ const cancelRequest = () => { ++ if (!requestToken.isCancellationRequested) { ++ request.cancel(); ++ } ++ }; ++ const connectionSubscription = this.lifetime.token.onCancellationRequested(cancelRequest); ++ const requestSubscription = token?.onCancellationRequested(cancelRequest); ++ if (this.lifetime.token.isCancellationRequested || token?.isCancellationRequested) { ++ cancelRequest(); ++ } ++ try { ++ return await withCancellation( ++ () => this.provider.resolve(params, requestToken), ++ requestToken ++ ); ++ } finally { ++ connectionSubscription.dispose(); ++ requestSubscription?.dispose(); ++ request.dispose(); ++ } ++ } ++} +diff --git a/nodejs/src/generated/rpc.ts b/nodejs/src/generated/rpc.ts +index f4978de..8f58bce 100644 +--- a/nodejs/src/generated/rpc.ts ++++ b/nodejs/src/generated/rpc.ts +@@ -3,7 +3,7 @@ + * Generated from: api.schema.json + */ + +-import type { MessageConnection } from "vscode-jsonrpc/node.js"; ++import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; + + import type { AbortReason, AgentModelPolicy, Attachment, AutoTier, ContextTier, EmbeddedBlobResourceContents, EmbeddedTextResourceContents, McpOauthHttpResponse, McpOauthWWWAuthenticateParams, McpServerMetadata, McpServerSource, McpServerStatus, ModelChangeSource, PermissionMode, PermissionPromptRequest, PermissionRule, ReasoningSummary, RemediationAction, SessionEvent, SessionLimitsConfig, SessionMode, ShutdownType, SkillSource, TaskCompleteData, TaskCompletionOutcome, UserToolSessionApproval, Verbosity } from "./session-events.js"; + +@@ -7483,16 +7483,24 @@ export interface ExtensionLaunchProviderResolveRequest { + */ + modulePath: string; + source: ExtensionSource; ++ /** ++ * Owning runtime session identifier, when known. ++ */ ++ sessionId?: string; ++ defaultLaunch?: ExtensionLaunchProfile; + } + /** +- * The launch profile for a supported entrypoint. Omit launch when the provider does not support the entrypoint. ++ * The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher. + * + * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema + * via the `definition` "ExtensionLaunchProviderResolveResult". + */ + /** @experimental */ + export interface ExtensionLaunchProviderResolveResult { +- launch?: ExtensionLaunchProfile; ++ /** ++ * Approved launch profile, or absent/null to deny this candidate without fallback. ++ */ ++ launch?: ExtensionLaunchProfile | null; + } + /** + * Extensions discovered for the session, with their current status. +@@ -23577,6 +23585,32 @@ export interface WorkspacesWriteAutopilotObjectiveResult { + */ + operation: string; + } ++/** ++ * Authoritative capability acknowledgement for the registered extension launch provider. ++ * ++ * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema ++ * via the `definition` "ExtensionLaunchProviderRegistrationResult". ++ */ ++/** @experimental */ ++export interface ExtensionLaunchProviderRegistrationResult { ++ /** ++ * Supported extension launch-provider contract version. Clients requiring this contract must check for version 1 before creating or resuming sessions. ++ */ ++ contractVersion: 1; ++} ++/** ++ * Identifies the target session. ++ * ++ * This interface was referenced by `_RpcSchemaRoot`'s JSON-Schema ++ * via the `definition` "SessionRetainRequest". ++ */ ++/** @experimental */ ++export interface SessionRetainRequest { ++ /** ++ * Target session identifier ++ */ ++ sessionId: string; ++} + + /** @experimental */ + export interface SessionModelListRequest { +@@ -23919,11 +23953,13 @@ export function createServerRpc(connection: MessageConnection) { + connection.sendRequest("extensions.disable", params), + }, + /** +- * Registers the calling SDK client as the per-entrypoint extension launch provider. Call before creating any sessions. When omitted, the runtime uses its built-in extension launcher. ++ * Registers the calling SDK client as the authoritative per-entrypoint extension launch provider and returns the supported contract version. Call before creating any sessions. Contract version 1 supplies sessionId and defaultLaunch when available; absent or null launch, provider errors, timeouts, and shutdown cancellation never fall back. Without a registered provider, legacy launching is unchanged. ++ * ++ * @returns Authoritative capability acknowledgement for the registered extension launch provider. + * + * @experimental + */ +- registerExtensionLaunchProvider: async (): Promise => ++ registerExtensionLaunchProvider: async (): Promise => + connection.sendRequest("registerExtensionLaunchProvider", {}), + /** @experimental */ + catalog: { +@@ -24456,6 +24492,16 @@ export function createServerRpc(connection: MessageConnection) { + spawn: async (params: AgentRegistrySpawnRequest): Promise => + connection.sendRequest("agentRegistry.spawn", params), + }, ++ /** @experimental */ ++ session: { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @param params Identifies the target session. ++ */ ++ retain: async (params: SessionRetainRequest): Promise => ++ connection.sendRequest("session.retain", params), ++ }, + }; + } + +@@ -24554,6 +24600,13 @@ export function createInternalServerRpc(connection: MessageConnection) { + /** Create typed session-scoped RPC methods. */ + export function createSessionRpc(connection: MessageConnection, sessionId: string) { + return { ++ /** ++ * Records explicit persistence intent for a local session and flushes its pending state before returning, even without a user or assistant turn. Await this before an admitted potentially effectful canvas open or other non-chat operation. Retention survives stop and cold resume, is idempotent, and is never rolled back on later operation failure or cancellation. Does not run a prompt, grant permissions, or prevent explicit session deletion. Unsupported for remote sessions. ++ * ++ * @experimental ++ */ ++ retain: async (): Promise => ++ connection.sendRequest("session.retain", { sessionId }), + /** + * Suspends the session while preserving persisted state for later resume. + * +@@ -27160,13 +27213,13 @@ export function registerClientSessionApiHandlers( + /** @experimental */ + export interface ExtensionLaunchProviderHandler { + /** +- * Asks the registered SDK client to resolve an opaque process launch profile for one discovered extension entrypoint immediately before launch or reload. The provider must respond within 15 seconds. ++ * Asks the registered SDK client to approve a launch profile immediately before every extension launch or reload. Return defaultLaunch unchanged to approve the runtime's built-in launcher, or return another profile. An absent or null launch denies execution with no fallback. The provider must respond within 15 seconds. Approval does not sandbox code or freeze mutable files; the host is responsible for approved package contents. + * + * @param params A discovered extension entrypoint that the registered integrator may classify and resolve to an opaque launch profile. + * +- * @returns The launch profile for a supported entrypoint. Omit launch when the provider does not support the entrypoint. ++ * @returns The approved launch profile. An absent or null launch denies execution; the runtime never falls back to its built-in launcher. + */ +- resolve(params: ExtensionLaunchProviderResolveRequest): Promise; ++ resolve(params: ExtensionLaunchProviderResolveRequest, token?: CancellationToken): Promise; + } + + /** Handler for `llmInference` client global API methods. */ +@@ -27179,7 +27232,7 @@ export interface LlmInferenceHandler { + * + * @returns Acknowledgement. Returning successfully simply means the SDK accepted the start frame; it does not imply the request will succeed. + */ +- httpRequestStart(params: LlmInferenceHttpRequestStartRequest): Promise; ++ httpRequestStart(params: LlmInferenceHttpRequestStartRequest, token?: CancellationToken): Promise; + /** + * Delivers a body byte range (or a cancellation signal) for a request previously announced via httpRequestStart, correlated by requestId. The runtime fires at least one chunk per request — when there is no body, a single chunk with empty data and end=true. Mid-stream the runtime may send a chunk with cancel=true to abort the request; the SDK then stops issuing httpResponseChunk frames and may emit a terminal httpResponseChunk with error set. + * +@@ -27187,7 +27240,7 @@ export interface LlmInferenceHandler { + * + * @returns Acknowledgement. The SDK is free to ignore the ack and treat chunk delivery as fire-and-forget. + */ +- httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest): Promise; ++ httpRequestChunk(params: LlmInferenceHttpRequestChunkRequest, token?: CancellationToken): Promise; + } + + /** Handler for `gitHubTelemetry` client global API methods. */ +@@ -27211,7 +27264,7 @@ export interface GitHubTokenHandler { + * + * @returns SDK host response to a GitHub credential request. + */ +- getToken(params: GitHubTokenAcquireRequest): Promise; ++ getToken(params: GitHubTokenAcquireRequest, token?: CancellationToken): Promise; + } + + /** All client global API handler groups. */ +@@ -27233,29 +27286,29 @@ export function registerClientGlobalApiHandlers( + connection: MessageConnection, + handlers: ClientGlobalApiHandlers, + ): void { +- connection.onRequest("extensionLaunchProvider.resolve", async (params: ExtensionLaunchProviderResolveRequest) => { ++ connection.onRequest("extensionLaunchProvider.resolve", async (params: ExtensionLaunchProviderResolveRequest, token: CancellationToken) => { + const handler = handlers.extensionLaunchProvider; + if (!handler) throw new Error("No extensionLaunchProvider client-global handler registered"); +- return handler.resolve(params); ++ return handler.resolve(params, token); + }); +- connection.onRequest("llmInference.httpRequestStart", async (params: LlmInferenceHttpRequestStartRequest) => { ++ connection.onRequest("llmInference.httpRequestStart", async (params: LlmInferenceHttpRequestStartRequest, token: CancellationToken) => { + const handler = handlers.llmInference; + if (!handler) throw new Error("No llmInference client-global handler registered"); +- return handler.httpRequestStart(params); ++ return handler.httpRequestStart(params, token); + }); +- connection.onRequest("llmInference.httpRequestChunk", async (params: LlmInferenceHttpRequestChunkRequest) => { ++ connection.onRequest("llmInference.httpRequestChunk", async (params: LlmInferenceHttpRequestChunkRequest, token: CancellationToken) => { + const handler = handlers.llmInference; + if (!handler) throw new Error("No llmInference client-global handler registered"); +- return handler.httpRequestChunk(params); ++ return handler.httpRequestChunk(params, token); + }); + connection.onNotification("gitHubTelemetry.event", async (params: GitHubTelemetryNotification) => { + const handler = handlers.gitHubTelemetry; + if (!handler) return; + await handler.event(params); + }); +- connection.onRequest("gitHubToken.getToken", async (params: GitHubTokenAcquireRequest) => { ++ connection.onRequest("gitHubToken.getToken", async (params: GitHubTokenAcquireRequest, token: CancellationToken) => { + const handler = handlers.gitHubToken; + if (!handler) throw new Error("No gitHubToken client-global handler registered"); +- return handler.getToken(params); ++ return handler.getToken(params, token); + }); + } +diff --git a/nodejs/src/generated/session-events.ts b/nodejs/src/generated/session-events.ts +index 02fbad6..08fdfc1 100644 +--- a/nodejs/src/generated/session-events.ts ++++ b/nodejs/src/generated/session-events.ts +@@ -20,6 +20,7 @@ export type SessionEvent = + | ScheduleCancelledEvent + | ScheduleRearmedEvent + | AutopilotObjectiveChangedEvent ++ | RetainedEvent + | InfoEvent + | WarningEvent + | ModelChangeEvent +@@ -1782,6 +1783,42 @@ export interface AutopilotObjectiveChangedData { + operation: AutopilotObjectiveChangedOperation; + status?: AutopilotObjectiveChangedStatus; + } ++/** ++ * Session event "session.retained". Explicit host intent to persist this local session independently of conversation turns. Emitted by session.retain before a potentially effectful non-chat operation; not a user or assistant message. ++ */ ++/** @experimental */ ++export interface RetainedEvent { ++ /** ++ * Sub-agent instance identifier. Absent for events from the root/main agent and session-level events. ++ */ ++ agentId?: string; ++ data: RetainedData; ++ /** ++ * When true, the event is transient and not persisted to the session event log on disk ++ */ ++ ephemeral?: boolean; ++ /** ++ * Unique event identifier (UUID v4), generated when the event is emitted ++ */ ++ id: string; ++ /** ++ * ID of the chronologically preceding event in the session, forming a linked chain. Null for the first event. ++ */ ++ parentId: string | null; ++ /** ++ * ISO 8601 timestamp when the event was created ++ */ ++ timestamp: string; ++ /** ++ * Type discriminator. Always "session.retained". ++ */ ++ type: "session.retained"; ++} ++/** ++ * Explicit host intent to persist this local session independently of conversation turns. Emitted by session.retain before a potentially effectful non-chat operation; not a user or assistant message. ++ */ ++/** @experimental */ ++export interface RetainedData {} + /** + * Session event "session.info". Informational message for timeline display with categorization + */ +diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts +index 2007679..0609b13 100644 +--- a/nodejs/src/index.ts ++++ b/nodejs/src/index.ts +@@ -92,6 +92,12 @@ export type { + ExitPlanModeRequest, + ExitPlanModeResult, + ExtensionInfo, ++ ExtensionLaunchProfile, ++ ExtensionLaunchProviderHandler, ++ ExtensionLaunchProviderRegistrationResult, ++ ExtensionLaunchProviderResolveRequest, ++ ExtensionLaunchProviderResolveResult, ++ ExtensionSource, + ForegroundSessionInfo, + GetAuthStatusResponse, + GetStatusResponse, +@@ -170,6 +176,7 @@ export type { + SessionContext, + SessionListFilter, + SessionMetadata, ++ SessionRetainRequest, + SessionUiApi, + SessionFsConfig, + SessionFsProvider, +diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts +index 0f15749..9a8d03e 100644 +--- a/nodejs/src/types.ts ++++ b/nodejs/src/types.ts +@@ -22,6 +22,7 @@ import type { + import type { CopilotSession } from "./session.js"; + import type { FactoryJsonSchema, JsonValue } from "./factory.js"; + import type { ++ ExtensionLaunchProviderHandler, + GitHubTokenAcquireRequest, + GitHubTokenAcquireResult, + GitHubTelemetryNotification, +@@ -29,10 +30,20 @@ import type { + OpenCanvasInstance, + RemoteSessionMode, + CurrentToolMetadata, ++ SessionOpenOptions, + } from "./generated/rpc.js"; + import type { ToolSet } from "./toolSet.js"; + export type { RemoteSessionMode } from "./generated/rpc.js"; + export type { CurrentToolMetadata } from "./generated/rpc.js"; ++export type { ++ ExtensionLaunchProfile, ++ ExtensionLaunchProviderHandler, ++ ExtensionLaunchProviderRegistrationResult, ++ ExtensionLaunchProviderResolveRequest, ++ ExtensionLaunchProviderResolveResult, ++ ExtensionSource, ++ SessionRetainRequest, ++} from "./generated/rpc.js"; + export type { + GitHubTokenAcquireReason, + GitHubTokenAcquireResult, +@@ -484,6 +495,30 @@ export interface CopilotClientOptions { + */ + requestHandler?: CopilotRequestHandler; + ++ /** ++ * Connection-owned extension launch admission handler. ++ * ++ * Attached before the RPC handshake. `start()` registers it and requires an explicit ++ * contract-version-1 acknowledgement before create/resume can proceed. ++ * Missing support, invalid acknowledgements, and registration errors reject ++ * startup; they never opt back into the runtime's legacy launcher. ++ * ++ * Each resolve receives the original source identity and optional runtime ++ * session/default-launch context. Return `defaultLaunch` unchanged only ++ * after approving the source and completing any required retention through ++ * `client.rpc.session.retain({ sessionId })`. An absent or null launch denies ++ * execution. The optional cancellation token is cancelled on request ++ * cancellation, disconnect, or stop; late results are not reused. ++ * ++ * Reconnecting negotiates a new registration and resolves each launch anew. ++ * A runtime that keeps a disconnected provider authoritative may refuse ++ * replacement; that error is propagated rather than bypassing the old owner. ++ * Omitting this option preserves legacy runtime extension behavior. ++ * ++ * @experimental ++ */ ++ extensionLaunchProvider?: ExtensionLaunchProviderHandler; ++ + /** + * Experimental. Receives GitHub telemetry events the runtime forwards to + * this connection. When set, the client opts each session it creates or +@@ -2563,6 +2598,18 @@ export interface SessionConfigBase { + */ + enableFileChangeTracking?: boolean; + ++ /** ++ * Enables read-only classification of built-in shell commands. When true, ++ * commands classified as read-only may run without a permission prompt, ++ * subject to runtime policy. This is not an extension sandbox. ++ * ++ * Applied during session creation or resume, before new extension ++ * initialization. Omission preserves the runtime's existing behavior. ++ * ++ * @experimental ++ */ ++ enableScriptSafety?: SessionOpenOptions["enableScriptSafety"]; ++ + /** + * Limits applied to this session's current accounting window. + * +diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts +index 3db96ea..40daf5f 100644 +--- a/nodejs/test/client.test.ts ++++ b/nodejs/test/client.test.ts +@@ -62,6 +62,64 @@ describe("approveAll", () => { + }); + + describe("CopilotClient", () => { ++ it("start() is single-flight: concurrent callers share one startup", async () => { ++ const client = new CopilotClient({ autoStart: false }); ++ onTestFinished(() => client.forceStop()); ++ ++ // Stub the underlying startup (doStart) that the single-flight guard ++ // dedupes. Transport-independent: this is the same regardless of the ++ // stdio vs in-process connection path. The delay makes all three ++ // start() calls overlap; on success it marks the client connected like ++ // the real doStart does. ++ const doStart = vi.fn().mockImplementation( ++ () => ++ new Promise((resolve) => ++ setTimeout(() => { ++ (client as any).state = "connected"; ++ resolve(); ++ }, 50) ++ ) ++ ); ++ (client as any).doStart = doStart; ++ ++ // Before the fix, each concurrent caller ran startup (and spawned its own ++ // CLI, orphaning all but the last). With single-flight they share one. ++ await Promise.all([client.start(), client.start(), client.start()]); ++ ++ expect(doStart).toHaveBeenCalledTimes(1); ++ expect((client as any).state).toBe("connected"); ++ ++ // Once connected, a further start() is a no-op (no extra startup). ++ await client.start(); ++ expect(doStart).toHaveBeenCalledTimes(1); ++ }); ++ ++ it("start() retries after a failed attempt (single-flight guard is cleared)", async () => { ++ const client = new CopilotClient({ autoStart: false }); ++ onTestFinished(() => client.forceStop()); ++ ++ // Stub the underlying startup: fail once, then succeed. Transport- ++ // independent (does not depend on the stdio vs in-process path). ++ const doStart = vi ++ .fn() ++ .mockImplementationOnce(async () => { ++ (client as any).state = "error"; ++ throw new Error("boom"); ++ }) ++ .mockImplementationOnce(async () => { ++ (client as any).state = "connected"; ++ }); ++ (client as any).doStart = doStart; ++ ++ await expect(client.start()).rejects.toThrow(/boom/); ++ expect((client as any).state).toBe("error"); ++ ++ // The guard must have cleared so a later start() can retry. ++ await client.start(); ++ expect(doStart).toHaveBeenCalledTimes(2); ++ expect((client as any).state).toBe("connected"); ++ }); ++ + it.each([ + { + source: "connection path", +@@ -4072,6 +4130,7 @@ describe("CopilotClient", () => { + onNotification: vi.fn(), + onRequest: vi.fn(), + onClose: vi.fn(), ++ onDispose: vi.fn(), + onError: vi.fn(), + }; + +diff --git a/nodejs/test/extension-launch-provider.test.ts b/nodejs/test/extension-launch-provider.test.ts +new file mode 100644 +index 0000000..7a075da +--- /dev/null ++++ b/nodejs/test/extension-launch-provider.test.ts +@@ -0,0 +1,947 @@ ++/*--------------------------------------------------------------------------------------------- ++ * Copyright (c) Microsoft Corporation. All rights reserved. ++ *--------------------------------------------------------------------------------------------*/ ++ ++import { randomUUID } from "node:crypto"; ++import { once } from "node:events"; ++import { createServer, type Socket } from "node:net"; ++import { setTimeout } from "node:timers/promises"; ++import { describe, expect, expectTypeOf, it, onTestFinished, vi } from "vitest"; ++import { ++ CancellationTokenSource, ++ createMessageConnection, ++ ErrorCodes, ++ ResponseError, ++ StreamMessageReader, ++ StreamMessageWriter, ++ type CancellationToken, ++ type MessageConnection, ++} from "vscode-jsonrpc/node.js"; ++import { ++ CopilotClient, ++ RuntimeConnection, ++ type CopilotClientOptions, ++ type ExtensionLaunchProfile, ++ type ExtensionLaunchProviderHandler, ++ type ExtensionLaunchProviderRegistrationResult, ++ type ExtensionLaunchProviderResolveRequest, ++ type ExtensionLaunchProviderResolveResult, ++ type ExtensionSource, ++ type PermissionRequestedEvent, ++ type ResumeSessionConfig, ++ type RetainedEvent, ++ type SessionConfig, ++ type SessionEvent, ++ type SessionRetainRequest, ++} from "../src/index.js"; ++import { ExtensionLaunchProviderConnection } from "../src/extensionLaunchProvider.js"; ++import type { PermissionDecisionRequest, SessionOpenOptions } from "../src/generated/rpc.js"; ++ ++function deferred() { ++ let resolve!: (value: T | PromiseLike) => void; ++ const promise = new Promise((complete) => { ++ resolve = complete; ++ }); ++ return { promise, resolve }; ++} ++ ++// A synthetic loopback runtime peer, not an injected client connection or handler table. ++async function runtimePeer(configure: (connection: MessageConnection) => void = () => {}) { ++ const peers: { connection: MessageConnection; socket: Socket }[] = []; ++ const clients: CopilotClient[] = []; ++ const server = createServer((socket) => { ++ const connection = createMessageConnection( ++ new StreamMessageReader(socket), ++ new StreamMessageWriter(socket) ++ ); ++ peers.push({ connection, socket }); ++ connection.onRequest("connect", () => ({ protocolVersion: 3 })); ++ connection.onRequest("registerExtensionLaunchProvider", () => ({ contractVersion: 1 })); ++ connection.onRequest("session.create", (params: { sessionId: string }) => ({ ++ sessionId: params.sessionId, ++ })); ++ connection.onRequest("session.resume", (params: { sessionId: string }) => ({ ++ sessionId: params.sessionId, ++ })); ++ connection.onRequest("session.detach", () => ({ success: true })); ++ connection.onClose(() => connection.dispose()); ++ configure(connection); ++ connection.listen(); ++ }); ++ onTestFinished(async () => { ++ const errors: Error[] = []; ++ try { ++ for (const client of clients) { ++ errors.push(...(await client.stop())); ++ } ++ } finally { ++ for (const peer of peers) { ++ peer.connection.dispose(); ++ peer.socket.destroy(); ++ } ++ await new Promise((resolve, reject) => { ++ server.close((error) => (error ? reject(error) : resolve())); ++ }); ++ } ++ expect(errors).toEqual([]); ++ }); ++ server.listen(0, "127.0.0.1"); ++ await once(server, "listening"); ++ const address = server.address(); ++ if (!address || typeof address === "string") { ++ throw new Error("Expected a loopback TCP listener"); ++ } ++ return { ++ peers, ++ client(options: Omit = {}) { ++ const client = new CopilotClient({ ++ ...options, ++ connection: RuntimeConnection.forUri(`127.0.0.1:${address.port}`), ++ }); ++ clients.push(client); ++ return client; ++ }, ++ }; ++} ++ ++const profile: ExtensionLaunchProfile = { ++ executable: "/synthetic/bin/node", ++ args: ["--import", "/original directory/bootstrap.mjs", "/original directory/extension.mjs"], ++ env: { SYNTHETIC_LITERAL: "literal value", COPILOT_SDK_PATH: "/runtime-selected/sdk" }, ++}; ++const candidate: ExtensionLaunchProviderResolveRequest = { ++ id: "project:fixture", ++ name: "fixture", ++ modulePath: "/original directory/extension.mjs", ++ source: "project", ++ sessionId: "unit-session", ++ defaultLaunch: profile, ++}; ++const grant: ExtensionLaunchProviderHandler = { ++ resolve: async (request) => ({ launch: request.defaultLaunch }), ++}; ++ ++describe("public script safety lifecycle configuration", () => { ++ it("uses the canonical optional setting for both public configs", () => { ++ expectTypeOf().toEqualTypeOf< ++ SessionOpenOptions["enableScriptSafety"] ++ >(); ++ expectTypeOf().toEqualTypeOf< ++ boolean | undefined ++ >(); ++ }); ++ ++ describe.each(["create", "resume"])("%s", (operation) => { ++ it.each([undefined, false, true])( ++ "forwards %j before the resolver and pre-return permission handling", ++ async (enableScriptSafety) => { ++ let returned = false; ++ const order: string[] = []; ++ const events: SessionEvent[] = []; ++ const responded = deferred(); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest( ++ "session.permissions.handlePendingPermissionRequest", ++ (params: PermissionDecisionRequest & SessionRetainRequest) => { ++ expect(params).toEqual({ ++ sessionId: "script-safety-session", ++ requestId: "early-permission", ++ result: { kind: "reject" }, ++ }); ++ order.push("permission-response"); ++ responded.resolve(); ++ return { success: true }; ++ } ++ ); ++ connection.onRequest( ++ `session.${operation}`, ++ async ( ++ params: SessionRetainRequest & ++ Pick ++ ) => { ++ expect(params.enableScriptSafety).toBe(enableScriptSafety); ++ expect(Object.hasOwn(params, "enableScriptSafety")).toBe( ++ enableScriptSafety !== undefined ++ ); ++ order.push("initial-request"); ++ await expect( ++ connection.sendRequest("extensionLaunchProvider.resolve", { ++ ...candidate, ++ sessionId: params.sessionId, ++ }) ++ ).resolves.toEqual({ launch: profile }); ++ const event: PermissionRequestedEvent = { ++ type: "permission.requested", ++ id: randomUUID(), ++ timestamp: new Date().toISOString(), ++ parentId: null, ++ data: { ++ requestId: "early-permission", ++ permissionRequest: { ++ kind: "shell", ++ canOfferSessionApproval: false, ++ commands: [], ++ fullCommandText: "pwd", ++ hasWriteFileRedirection: false, ++ intention: "Synthetic early permission routing", ++ possiblePaths: [], ++ possibleUrls: [], ++ }, ++ }, ++ }; ++ await connection.sendNotification("session.event", { ++ sessionId: params.sessionId, ++ event, ++ }); ++ await responded.promise; ++ expect(returned).toBe(false); ++ return { sessionId: params.sessionId }; ++ } ++ ); ++ }); ++ const client = runtime.client({ ++ extensionLaunchProvider: { ++ resolve: async () => { ++ expect(returned).toBe(false); ++ order.push("resolver"); ++ return { launch: profile }; ++ }, ++ }, ++ }); ++ const config: SessionConfig = { ++ ...(enableScriptSafety === undefined ? {} : { enableScriptSafety }), ++ requestExtensions: true, ++ onEvent: (event) => events.push(event), ++ onPermissionRequest: (_, context) => { ++ expect(returned).toBe(false); ++ expect(context.sessionId).toBe("script-safety-session"); ++ order.push("permission-handler"); ++ return { kind: "reject" }; ++ }, ++ }; ++ const session = ++ operation === "create" ++ ? await client.createSession({ ++ ...config, ++ sessionId: "script-safety-session", ++ }) ++ : await client.resumeSession("script-safety-session", config); ++ returned = true; ++ expect(session.sessionId).toBe("script-safety-session"); ++ expect(order).toEqual([ ++ "initial-request", ++ "resolver", ++ "permission-handler", ++ "permission-response", ++ ]); ++ expect(events.map((event) => event.type)).toEqual(["permission.requested"]); ++ } ++ ); ++ }); ++}); ++ ++describe("public extension launch provider attachment", () => { ++ it("does not register a provider when the option is omitted", async () => { ++ const register = vi.fn(() => ({ contractVersion: 1 })); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", register); ++ }); ++ const client = runtime.client(); ++ const session = await client.createSession({}); ++ await client.resumeSession(session.sessionId, {}); ++ expect(register).not.toHaveBeenCalled(); ++ }); ++ ++ describe.each(["start", "create", "resume"])("%s negotiation", (operation) => { ++ it.each([ ++ null, ++ undefined, ++ {}, ++ { contractVersion: 0 }, ++ { contractVersion: 2 }, ++ { contractVersion: "1" }, ++ ])( ++ "rejects an old or invalid acknowledgement %j before creating/resuming", ++ async (acknowledgement) => { ++ const create = vi.fn(); ++ const resume = vi.fn(); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", () => acknowledgement); ++ connection.onRequest("session.create", create); ++ connection.onRequest("session.resume", resume); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: grant }); ++ const result = ++ operation === "start" ++ ? client.start() ++ : operation === "create" ++ ? client.createSession({}) ++ : client.resumeSession("unit-session", {}); ++ await expect(result).rejects.toThrow("requires contract version 1"); ++ expect(create).not.toHaveBeenCalled(); ++ expect(resume).not.toHaveBeenCalled(); ++ expect(() => client.rpc).toThrow("not connected"); ++ } ++ ); ++ }); ++ ++ it.each([ErrorCodes.MethodNotFound, -32001])( ++ "preserves registration error %s without a fallback", ++ async (code) => { ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest( ++ "registerExtensionLaunchProvider", ++ () => ++ new ResponseError(code, "registration refused", { owner: "another-client" }) ++ ); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: grant }); ++ await expect(client.start()).rejects.toMatchObject({ ++ code, ++ message: "registration refused", ++ data: { owner: "another-client" }, ++ }); ++ expect(() => client.rpc).toThrow("not connected"); ++ } ++ ); ++ ++ it("gates overlapping start/create/resume calls and registers once per connection", async () => { ++ const entered = deferred(); ++ const acknowledgement = deferred(); ++ const register = vi.fn(() => { ++ entered.resolve(); ++ return acknowledgement.promise; ++ }); ++ const create = vi.fn((params: { sessionId: string }) => ({ sessionId: params.sessionId })); ++ const resume = vi.fn((params: { sessionId: string }) => ({ sessionId: params.sessionId })); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", register); ++ connection.onRequest("session.create", create); ++ connection.onRequest("session.resume", resume); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: grant }); ++ const start = client.start(); ++ await entered.promise; ++ const creating = client.createSession({ sessionId: "created" }); ++ const resuming = client.resumeSession("resumed", {}); ++ await setTimeout(20); ++ expect(create).not.toHaveBeenCalled(); ++ expect(resume).not.toHaveBeenCalled(); ++ acknowledgement.resolve({ contractVersion: 1 }); ++ await Promise.all([start, client.start(), creating, resuming]); ++ await expect(client.rpc.registerExtensionLaunchProvider()).resolves.toEqual({ ++ contractVersion: 1, ++ }); ++ await expect(client.rpc.registerExtensionLaunchProvider()).resolves.toEqual({ ++ contractVersion: 1, ++ }); ++ expect(register).toHaveBeenCalledTimes(1); ++ expect(create).toHaveBeenCalledTimes(1); ++ expect(resume).toHaveBeenCalledTimes(1); ++ }); ++ ++ it("attaches before registration but does not approve before acknowledgement", async () => { ++ const resolve = vi.fn(grant.resolve); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", async () => { ++ await expect( ++ connection.sendRequest("extensionLaunchProvider.resolve", candidate) ++ ).rejects.toThrow("has not been acknowledged"); ++ return { contractVersion: 1 }; ++ }); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ await client.start(); ++ expect(resolve).not.toHaveBeenCalled(); ++ await expect( ++ runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", candidate) ++ ).resolves.toEqual({ launch: profile }); ++ expect(resolve).toHaveBeenCalledTimes(1); ++ }); ++ ++ it.each(["project", "user", "plugin", "session"])( ++ "preserves %s source, path, identity, context and opaque launch recipe", ++ async (source) => { ++ const request: ExtensionLaunchProviderResolveRequest = { ++ ...candidate, ++ id: `${source}:fixture`, ++ source, ++ }; ++ const resolve = vi.fn(grant.resolve); ++ const runtime = await runtimePeer(); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ await client.start(); ++ await expect( ++ runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", request) ++ ).resolves.toEqual({ launch: profile }); ++ expect(resolve.mock.calls[0][0]).toEqual(request); ++ expect(resolve.mock.calls[0][1]?.isCancellationRequested).toBe(false); ++ } ++ ); ++ ++ it("does not invent optional session or bootstrap context", async () => { ++ const request: ExtensionLaunchProviderResolveRequest = { ++ id: candidate.id, ++ name: candidate.name, ++ modulePath: candidate.modulePath, ++ source: candidate.source, ++ }; ++ const resolve = vi.fn(async () => ({})); ++ const runtime = await runtimePeer(); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ await client.start(); ++ await expect( ++ runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", request) ++ ).resolves.toEqual({}); ++ expect(resolve).toHaveBeenCalledWith(request, expect.anything()); ++ }); ++ ++ it.each([{}, { launch: null }])( ++ "preserves an explicit denial %j", ++ async (denial) => { ++ const runtime = await runtimePeer(); ++ const client = runtime.client({ ++ extensionLaunchProvider: { resolve: async () => denial }, ++ }); ++ await client.start(); ++ await expect( ++ runtime.peers[0].connection.sendRequest( ++ "extensionLaunchProvider.resolve", ++ candidate ++ ) ++ ).resolves.toEqual(denial); ++ } ++ ); ++ ++ it("preserves callback error codes and data, with no success-shaped fallback", async () => { ++ const runtime = await runtimePeer(); ++ const client = runtime.client({ ++ extensionLaunchProvider: { ++ resolve: () => { ++ throw new ResponseError(-32005, "source approval failed", { ++ stage: "revision", ++ }); ++ }, ++ }, ++ }); ++ await client.start(); ++ await expect( ++ runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", candidate) ++ ).rejects.toMatchObject({ ++ code: -32005, ++ message: "source approval failed", ++ data: { stage: "revision" }, ++ }); ++ }); ++ ++ it.each<"stop" | "forceStop">(["stop", "forceStop"])( ++ "observes cancellation when a resolver synchronously calls %s and throws", ++ async (operation) => { ++ const runtime = await runtimePeer(); ++ const failure = new Error("synchronous provider failure"); ++ let stopping: Promise | undefined; ++ let cancelledSynchronously: boolean | undefined; ++ const resolve = vi.fn((_request, token) => { ++ stopping = client[operation](); ++ cancelledSynchronously = token?.isCancellationRequested; ++ throw failure; ++ }); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ await client.start(); ++ await expect( ++ runtime.peers[0].connection.sendRequest( ++ "extensionLaunchProvider.resolve", ++ candidate ++ ) ++ ).rejects.toBeInstanceOf(Error); ++ expect(resolve).toHaveBeenCalledTimes(1); ++ if (!stopping) throw new Error("The resolver did not initiate shutdown"); ++ expect(await stopping).toEqual(operation === "stop" ? [] : undefined); ++ expect(cancelledSynchronously).toBe(true); ++ await setTimeout(0); ++ } ++ ); ++ ++ it("cancels in-flight resolution and never reuses a late grant", async () => { ++ const entered = deferred(); ++ const lateGrant = deferred(); ++ let observedToken: CancellationToken | undefined; ++ const resolve = vi.fn( ++ async (_request, token) => { ++ observedToken = token; ++ entered.resolve(); ++ return lateGrant.promise; ++ } ++ ); ++ const runtime = await runtimePeer(); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ await client.start(); ++ const cancellation = new CancellationTokenSource(); ++ onTestFinished(() => cancellation.dispose()); ++ const request = runtime.peers[0].connection.sendRequest( ++ "extensionLaunchProvider.resolve", ++ candidate, ++ cancellation.token ++ ); ++ await entered.promise; ++ cancellation.cancel(); ++ await expect(request).rejects.toMatchObject({ code: -32800 }); ++ expect(observedToken?.isCancellationRequested).toBe(true); ++ lateGrant.resolve({ launch: profile }); ++ await expect( ++ runtime.peers[0].connection.sendRequest("extensionLaunchProvider.resolve", candidate) ++ ).resolves.toEqual({ launch: profile }); ++ expect(resolve).toHaveBeenCalledTimes(2); ++ }); ++ ++ it.each(["stop", "forceStop", "disconnect"])( ++ "%s cancels outstanding grants and reconnects with a fresh registration", ++ async (operation) => { ++ const entered = deferred(); ++ const lateGrant = deferred(); ++ const register = vi.fn(() => ({ contractVersion: 1 })); ++ let observedToken: CancellationToken | undefined; ++ const resolve = vi.fn( ++ async (_request, token) => { ++ observedToken = token; ++ entered.resolve(); ++ return lateGrant.promise; ++ } ++ ); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", register); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ await client.start(); ++ const originalRpc = client.rpc; ++ const pending = runtime.peers[0].connection.sendRequest( ++ "extensionLaunchProvider.resolve", ++ candidate ++ ); ++ const rejected = expect(pending).rejects.toBeInstanceOf(Error); ++ await entered.promise; ++ if (operation === "stop") { ++ expect(await client.stop()).toEqual([]); ++ } else if (operation === "forceStop") { ++ await client.forceStop(); ++ } else { ++ runtime.peers[0].socket.destroy(); ++ } ++ await rejected; ++ await expect.poll(() => observedToken?.isCancellationRequested).toBe(true); ++ lateGrant.resolve({ launch: profile }); ++ await expect(originalRpc.registerExtensionLaunchProvider()).rejects.toMatchObject({ ++ code: -32800, ++ }); ++ await client.start(); ++ expect(register).toHaveBeenCalledTimes(2); ++ expect(resolve).toHaveBeenCalledTimes(1); ++ await expect( ++ runtime.peers[1].connection.sendRequest( ++ "extensionLaunchProvider.resolve", ++ candidate ++ ) ++ ).resolves.toEqual({ launch: profile }); ++ expect(resolve).toHaveBeenCalledTimes(2); ++ } ++ ); ++ ++ it("surfaces a shared runtime's refusal to replace a disconnected provider", async () => { ++ let registrations = 0; ++ const create = vi.fn(); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", () => { ++ if (++registrations === 1) return { contractVersion: 1 }; ++ return new ResponseError( ++ -32603, ++ "Another client is already the extension launch provider." ++ ); ++ }); ++ connection.onRequest("session.create", create); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: grant }); ++ await client.start(); ++ expect(await client.stop()).toEqual([]); ++ await expect(client.createSession({})).rejects.toThrow( ++ "already the extension launch provider" ++ ); ++ expect(registrations).toBe(2); ++ expect(create).not.toHaveBeenCalled(); ++ expect(() => client.rpc).toThrow("not connected"); ++ }); ++ ++ it("stopping during negotiation rejects startup instead of accepting a late acknowledgement", async () => { ++ const entered = deferred(); ++ const acknowledgement = deferred(); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", () => { ++ entered.resolve(); ++ return acknowledgement.promise; ++ }); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: grant }); ++ const starting = client.start(); ++ const rejected = expect(starting).rejects.toBeInstanceOf(Error); ++ await entered.promise; ++ expect(await client.stop()).toEqual([]); ++ await rejected; ++ acknowledgement.resolve({ contractVersion: 1 }); ++ expect(() => client.rpc).toThrow("not connected"); ++ }); ++}); ++ ++describe("extension launch cancellation adapter", () => { ++ it("disposes an unused connection repeatedly and refuses later registration", async () => { ++ const register = vi.fn<() => Promise>( ++ async () => ({ contractVersion: 1 }) ++ ); ++ const connection = new ExtensionLaunchProviderConnection(grant, register); ++ onTestFinished(() => connection.dispose()); ++ connection.dispose(); ++ connection.dispose(); ++ await expect(connection.register()).rejects.toMatchObject({ code: -32800 }); ++ await expect(connection.register()).rejects.toMatchObject({ code: -32800 }); ++ expect(register).not.toHaveBeenCalled(); ++ }); ++ ++ it("handles synchronous overlapping wire and lifetime cancellation before callback entry", async () => { ++ const resolve = vi.fn(grant.resolve); ++ const connection = new ExtensionLaunchProviderConnection({ resolve }, async () => ({ ++ contractVersion: 1, ++ })); ++ onTestFinished(() => connection.dispose()); ++ await connection.register(); ++ const subscriptionDisposed = vi.fn(); ++ const token: CancellationToken = { ++ isCancellationRequested: true, ++ onCancellationRequested(listener) { ++ listener(undefined); ++ connection.dispose(); ++ listener(undefined); ++ return { dispose: subscriptionDisposed }; ++ }, ++ }; ++ await expect(connection.handler.resolve(candidate, token)).rejects.toMatchObject({ ++ code: -32800, ++ }); ++ expect(resolve).not.toHaveBeenCalled(); ++ expect(subscriptionDisposed).toHaveBeenCalledTimes(1); ++ await expect(connection.register()).rejects.toMatchObject({ code: -32800 }); ++ }); ++ ++ it("preserves synchronous invocation and the original error when the resolver disposes then throws", async () => { ++ const failure = new Error("synchronous provider failure"); ++ let entered = false; ++ const connection = new ExtensionLaunchProviderConnection( ++ { ++ resolve() { ++ entered = true; ++ connection.dispose(); ++ throw failure; ++ }, ++ }, ++ async () => ({ contractVersion: 1 }) ++ ); ++ onTestFinished(() => connection.dispose()); ++ await connection.register(); ++ const resolving = connection.handler.resolve(candidate); ++ expect(entered).toBe(true); ++ await expect(resolving).rejects.toBe(failure); ++ await setTimeout(0); ++ }); ++}); ++ ++describe("public launch provider cancellation lifecycle", () => { ++ it.each(["start", "create", "resume"])( ++ "%s preserves handshake failures before provider registration and supports repeated cleanup", ++ async (operation) => { ++ const diagnostics = vi.spyOn(console, "error"); ++ onTestFinished(() => diagnostics.mockRestore()); ++ let rejectHandshake = true; ++ const registration = vi.fn(() => ({ contractVersion: 1 })); ++ const resolve = vi.fn(grant.resolve); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("connect", () => ++ rejectHandshake ++ ? new ResponseError(-32041, "synthetic handshake failure", { ++ phase: "before-registration", ++ }) ++ : { protocolVersion: 3 } ++ ); ++ connection.onRequest("registerExtensionLaunchProvider", registration); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ const operationResult = ++ operation === "start" ++ ? client.start() ++ : operation === "create" ++ ? client.createSession({}) ++ : client.resumeSession("unit-session", {}); ++ await expect(operationResult).rejects.toMatchObject({ ++ code: -32041, ++ message: "synthetic handshake failure", ++ data: { phase: "before-registration" }, ++ }); ++ expect(registration).not.toHaveBeenCalled(); ++ expect(resolve).not.toHaveBeenCalled(); ++ expect(() => client.rpc).toThrow("not connected"); ++ await client.forceStop(); ++ expect(await client.stop()).toEqual([]); ++ await client.forceStop(); ++ expect(diagnostics).not.toHaveBeenCalled(); ++ ++ rejectHandshake = false; ++ await client.start(); ++ expect(registration).toHaveBeenCalledTimes(1); ++ expect(resolve).not.toHaveBeenCalled(); ++ expect(await client.stop()).toEqual([]); ++ expect(await client.stop()).toEqual([]); ++ await client.forceStop(); ++ expect(diagnostics).not.toHaveBeenCalled(); ++ } ++ ); ++ ++ it.each(["stop", "forceStop", "disconnect"])( ++ "%s before handshake completion safely cancels an unused provider lifetime", ++ async (operation) => { ++ const diagnostics = vi.spyOn(console, "error"); ++ onTestFinished(() => diagnostics.mockRestore()); ++ const entered = deferred(); ++ const handshake = deferred<{ protocolVersion: number }>(); ++ const registration = vi.fn(() => ({ contractVersion: 1 })); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("connect", () => { ++ entered.resolve(); ++ return handshake.promise; ++ }); ++ connection.onRequest("registerExtensionLaunchProvider", registration); ++ }); ++ const client = runtime.client({ extensionLaunchProvider: grant }); ++ const starting = client.start(); ++ const failure = starting.catch((error: unknown) => error); ++ await entered.promise; ++ const originalRpc = client.rpc; ++ if (operation === "disconnect") { ++ runtime.peers[0].socket.destroy(); ++ await expect ++ .poll(() => { ++ try { ++ return client.rpc; ++ } catch (error) { ++ return error; ++ } ++ }) ++ .toBeInstanceOf(Error); ++ await client.forceStop(); ++ } else if (operation === "forceStop") { ++ await client.forceStop(); ++ } else { ++ expect(await client.stop()).toEqual([]); ++ } ++ await expect(failure).resolves.toMatchObject({ ++ code: ErrorCodes.PendingResponseRejected, ++ }); ++ handshake.resolve({ protocolVersion: 3 }); ++ await expect(originalRpc.registerExtensionLaunchProvider()).rejects.toMatchObject({ ++ code: -32800, ++ }); ++ expect(registration).not.toHaveBeenCalled(); ++ expect(await client.stop()).toEqual([]); ++ await client.forceStop(); ++ expect(diagnostics).not.toHaveBeenCalled(); ++ } ++ ); ++ ++ it.each(["stop", "forceStop", "disconnect"])( ++ "overlapping wire cancellation and %s notify once and cannot replay a late grant", ++ async (operation) => { ++ const entered = deferred(); ++ const late = deferred(); ++ const notified = deferred(); ++ const diagnostics = vi.spyOn(console, "error"); ++ onTestFinished(() => diagnostics.mockRestore()); ++ const registration = vi.fn(() => ({ contractVersion: 1 })); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("registerExtensionLaunchProvider", registration); ++ }); ++ const freshProfile: ExtensionLaunchProfile = { ++ ...profile, ++ args: [...profile.args, "--fresh-resolution"], ++ }; ++ let notifications = 0; ++ let stopping: Promise[]> | undefined; ++ const resolve = vi ++ .fn() ++ .mockImplementationOnce(async (_request, token) => { ++ if (!token) throw new Error("Expected the public cancellation token"); ++ token.onCancellationRequested(() => { ++ notifications++; ++ if (operation === "disconnect") { ++ runtime.peers[0].socket.destroy(); ++ stopping = Promise.resolve([]); ++ } else { ++ stopping = Promise.allSettled([ ++ operation === "stop" ? client.stop() : client.forceStop(), ++ ]); ++ } ++ notified.resolve(); ++ }); ++ entered.resolve(); ++ return late.promise; ++ }) ++ .mockResolvedValue({ launch: freshProfile }); ++ const client = runtime.client({ extensionLaunchProvider: { resolve } }); ++ await client.start(); ++ const oldRpc = client.rpc; ++ const wireCancellation = new CancellationTokenSource(); ++ onTestFinished(() => wireCancellation.dispose()); ++ const pending = runtime.peers[0].connection.sendRequest( ++ "extensionLaunchProvider.resolve", ++ candidate, ++ wireCancellation.token ++ ); ++ const failed = expect(pending).rejects.toBeInstanceOf(Error); ++ await entered.promise; ++ wireCancellation.cancel(); ++ wireCancellation.cancel(); ++ await notified.promise; ++ if (!stopping) throw new Error("Cancellation did not initiate connection teardown"); ++ for (const result of await stopping) { ++ expect(result.status).toBe("fulfilled"); ++ if (result.status === "fulfilled") { ++ expect(result.value).toEqual(operation === "stop" ? [] : undefined); ++ } ++ } ++ await failed; ++ await client.forceStop(); ++ expect(await client.stop()).toEqual([]); ++ expect(notifications).toBe(1); ++ late.resolve({ launch: profile }); ++ await expect(oldRpc.registerExtensionLaunchProvider()).rejects.toMatchObject({ ++ code: -32800, ++ }); ++ await client.start(); ++ expect(registration).toHaveBeenCalledTimes(2); ++ expect(resolve).toHaveBeenCalledTimes(1); ++ await expect( ++ runtime.peers[1].connection.sendRequest( ++ "extensionLaunchProvider.resolve", ++ candidate ++ ) ++ ).resolves.toEqual({ launch: freshProfile }); ++ expect(resolve).toHaveBeenCalledTimes(2); ++ expect(diagnostics).not.toHaveBeenCalled(); ++ } ++ ); ++}); ++ ++describe("public no-turn retention bindings", () => { ++ it("retains reentrantly before create returns and delivers the early retained event", async () => { ++ let createReturned = false; ++ const events: SessionEvent[] = []; ++ const retained: SessionRetainRequest[] = []; ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("session.retain", async (params: SessionRetainRequest) => { ++ retained.push(params); ++ const event: RetainedEvent = { ++ type: "session.retained", ++ id: randomUUID(), ++ timestamp: new Date().toISOString(), ++ parentId: null, ++ data: {}, ++ }; ++ await connection.sendNotification("session.event", { ++ sessionId: params.sessionId, ++ event, ++ }); ++ return null; ++ }); ++ connection.onRequest("session.create", async (params: { sessionId: string }) => { ++ await expect( ++ connection.sendRequest("extensionLaunchProvider.resolve", { ++ ...candidate, ++ sessionId: params.sessionId, ++ }) ++ ).resolves.toEqual({ launch: profile }); ++ return { sessionId: params.sessionId }; ++ }); ++ }); ++ const client = runtime.client({ ++ extensionLaunchProvider: { ++ async resolve(request) { ++ expect(createReturned).toBe(false); ++ if (!request.sessionId) ++ throw new Error("Expected actual runtime session correlation"); ++ const result = await client.rpc.session.retain({ ++ sessionId: request.sessionId, ++ }); ++ expectTypeOf(result).toEqualTypeOf(); ++ expect(result).toBeNull(); ++ return { launch: request.defaultLaunch }; ++ }, ++ }, ++ }); ++ const session = await client.createSession({ onEvent: (event) => events.push(event) }); ++ createReturned = true; ++ expectTypeOf>>().toEqualTypeOf(); ++ expectTypeOf< ++ Parameters[0] ++ >().toEqualTypeOf(); ++ expect(events.map((event) => event.type)).toEqual(["session.retained"]); ++ expect(retained).toEqual([{ sessionId: session.sessionId }]); ++ await expect(session.rpc.retain()).resolves.toBeNull(); ++ expect(retained).toEqual([ ++ { sessionId: session.sessionId }, ++ { sessionId: session.sessionId }, ++ ]); ++ }); ++ ++ it.each([ ++ [ErrorCodes.MethodNotFound, "unsupported"], ++ [-32001, "persistence unavailable"], ++ [-32002, "writer flush failed"], ++ [-32800, "retention cancelled"], ++ ])("propagates %s (%s) from both bindings", async (code, message) => { ++ const retain = vi.fn(() => new ResponseError(code, message, { operation: "retain" })); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("session.retain", retain); ++ }); ++ const client = runtime.client(); ++ const session = await client.createSession({}); ++ await expect( ++ client.rpc.session.retain({ sessionId: session.sessionId }) ++ ).rejects.toMatchObject({ ++ code, ++ message, ++ data: { operation: "retain" }, ++ }); ++ await expect(session.rpc.retain()).rejects.toMatchObject({ ++ code, ++ message, ++ data: { operation: "retain" }, ++ }); ++ expect(retain).toHaveBeenCalledTimes(2); ++ }); ++ ++ it("rejects connection loss during retention and never retries the effect", async () => { ++ const entered = deferred(); ++ const flush = deferred(); ++ const retain = vi.fn(() => { ++ entered.resolve(); ++ return flush.promise; ++ }); ++ const runtime = await runtimePeer((connection) => { ++ connection.onRequest("session.retain", retain); ++ }); ++ const client = runtime.client(); ++ await client.start(); ++ const pending = client.rpc.session.retain({ sessionId: "unit-session" }); ++ const rejected = expect(pending).rejects.toBeInstanceOf(Error); ++ await entered.promise; ++ await client.forceStop(); ++ await rejected; ++ flush.resolve(null); ++ await client.start(); ++ expect(retain).toHaveBeenCalledTimes(1); ++ }); ++}); +diff --git a/nodejs/tsconfig.test.json b/nodejs/tsconfig.test.json +index 2957487..aa9d0de 100644 +--- a/nodejs/tsconfig.test.json ++++ b/nodejs/tsconfig.test.json +@@ -5,6 +5,10 @@ + "emitDeclarationOnly": false, + "types": ["node"] + }, +- "include": ["src/**/*", "test/session-event-types.test.ts"], ++ "include": [ ++ "src/**/*", ++ "test/session-event-types.test.ts", ++ "test/extension-launch-provider.test.ts" ++ ], + "exclude": ["node_modules", "dist"] + } +diff --git a/scripts/codegen/typescript.ts b/scripts/codegen/typescript.ts +index f5e8acb..cdbe983 100644 +--- a/scripts/codegen/typescript.ts ++++ b/scripts/codegen/typescript.ts +@@ -52,6 +52,8 @@ import { + } from "./utils.js"; + + const TS_EXPERIMENTAL_JSDOC = "/** @experimental */"; ++// Retention must also be callable before a create/resume response exposes a session. ++const CONNECTION_SESSION_METHODS = new Set(["session.retain"]); + const EXTERNAL_SCHEMA_TS_IMPORT: Record = { + "session-events.schema.json": "./session-events.js", + }; +@@ -678,7 +680,9 @@ function tsNullableResultTypeName(method: RpcMethod): string | undefined { + } + + function tsResultType(method: RpcMethod): string { +- if (isVoidSchema(getMethodResultSchema(method))) return "void"; ++ if (isVoidSchema(getMethodResultSchema(method))) { ++ return CONNECTION_SESSION_METHODS.has(method.rpcMethod) ? "null" : "void"; ++ } + return tsNullableResultTypeName(method) ?? resultTypeName(method); + } + +@@ -717,7 +721,7 @@ async function generateRpc(schemaPath?: string, sessionEventsSchema?: JSONSchema + * Generated from: api.schema.json + */ + +-import type { MessageConnection } from "vscode-jsonrpc/node.js"; ++import type { CancellationToken, MessageConnection } from "vscode-jsonrpc/node.js"; + `); + + const externalSchemaRefs = collectExternalSchemaRefNames(schema); +@@ -794,7 +798,11 @@ import type { MessageConnection } from "vscode-jsonrpc/node.js"; + if (paramsExternalRef) { + continue; + } +- if (method.rpcMethod.startsWith("session.") && resolvedParams?.properties) { ++ if ( ++ method.rpcMethod.startsWith("session.") && ++ !CONNECTION_SESSION_METHODS.has(method.rpcMethod) && ++ resolvedParams?.properties ++ ) { + const filtered: JSONSchema7 = { + ...resolvedParams, + properties: Object.fromEntries( +@@ -889,6 +897,14 @@ function hasInternalMethods(node: Record): boolean { + lines.push(`export function createServerRpc(connection: MessageConnection) {`); + lines.push(` return {`); + lines.push(...emitGroup(schema.server, " ", false, false, false, "public")); ++ const connectionSessionMethods = Object.fromEntries( ++ Object.entries(schema.session ?? {}).filter( ++ ([, method]) => isRpcMethod(method) && CONNECTION_SESSION_METHODS.has(method.rpcMethod) ++ ) ++ ); ++ if (Object.keys(connectionSessionMethods).length > 0) { ++ lines.push(...emitGroup({ session: connectionSessionMethods }, " ", false, false, false, "public")); ++ } + lines.push(` };`); + lines.push(`}`); + lines.push(""); +@@ -1211,7 +1227,8 @@ function emitClientGlobalApiRegistration(clientSchema: Record): + includeExperimental: method.stability === "experimental" && !groupExperimental, + }); + if (hasParams) { +- lines.push(` ${name}(params: ${pType}): Promise<${rType}>;`); ++ const cancellationParam = method.notification ? "" : ", token?: CancellationToken"; ++ lines.push(` ${name}(params: ${pType}${cancellationParam}): Promise<${rType}>;`); + } else { + lines.push(` ${name}(): Promise<${rType}>;`); + } +@@ -1270,10 +1287,10 @@ function emitClientGlobalApiRegistration(clientSchema: Record): + lines.push(` });`); + } + } else if (hasParams) { +- lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}) => {`); ++ lines.push(` connection.onRequest("${method.rpcMethod}", async (params: ${pType}, token: CancellationToken) => {`); + lines.push(` const handler = handlers.${groupName};`); + lines.push(` if (!handler) throw new Error("No ${groupName} client-global handler registered");`); +- lines.push(` return handler.${name}(params);`); ++ lines.push(` return handler.${name}(params, token);`); + lines.push(` });`); + } else { + lines.push(` connection.onRequest("${method.rpcMethod}", async () => {`); diff --git a/build/npm/copilotSdkCanvasPatch.ts b/build/npm/copilotSdkCanvasPatch.ts new file mode 100644 index 0000000000000..57e8e32b14abc --- /dev/null +++ b/build/npm/copilotSdkCanvasPatch.ts @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createHash, type BinaryLike } from 'crypto'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import path from 'path'; +import { root } from './installStateHash.ts'; + +/** A generated delta between the published SDK and its source-built canvas backport. */ +export interface CopilotSdkCanvasPatchManifest { + readonly schemaVersion: 1; + readonly packageName: '@github/copilot-sdk'; + readonly packageVersion: '1.0.13'; + readonly patchFile: string; + readonly patchSha256: string; + readonly before: Readonly>; + readonly after: Readonly>; +} + +export interface CopilotSdkCanvasPatchOptions { + readonly manifestPath?: string; + readonly checkOnly?: boolean; + readonly fileOperations?: Pick; +} + +interface PackageState { + readonly directory: string; + readonly state: 'before' | 'after'; +} + +interface PatchResult { + readonly directory: string; + readonly status: 'applied' | 'verified'; +} + +const hashPattern = /^[a-f0-9]{64}$/; + +function sha256(contents: BinaryLike): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isPackagePath(value: string): boolean { + return /^[A-Za-z0-9_@./-]+$/.test(value) + && value.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part !== '.git' && part !== 'node_modules'); +} + +function isHashVector(value: unknown): value is Readonly> { + return isRecord(value) && Object.keys(value).length > 0 + && Object.entries(value).every(([file, hash]) => isPackagePath(file) && typeof hash === 'string' && hashPattern.test(hash)) + && new Set(Object.keys(value).map(file => file.toLowerCase())).size === Object.keys(value).length; +} + +function isManifest(value: unknown): value is CopilotSdkCanvasPatchManifest { + return isRecord(value) + && value.schemaVersion === 1 + && value.packageName === '@github/copilot-sdk' + && value.packageVersion === '1.0.13' + && value.patchFile === 'copilot-sdk-canvas.patch' + && typeof value.patchSha256 === 'string' && hashPattern.test(value.patchSha256) + && isHashVector(value.before) && isHashVector(value.after); +} + +function readManifest(manifestPath: string): CopilotSdkCanvasPatchManifest { + const value: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (!isManifest(value)) { + throw new Error('Invalid Copilot SDK canvas backport manifest.'); + } + if (!Object.hasOwn(value.before, 'package.json')) { + throw new Error('The SDK backport must bind the published package metadata.'); + } + for (const file of Object.keys(value.before)) { + if (!Object.hasOwn(value.after, file)) { + throw new Error(`The SDK backport cannot remove a published file: ${file}`); + } + } + const changed = Object.keys(value.after).filter(file => value.before[file] !== value.after[file]); + if (changed.length === 0 || changed.some(file => !file.startsWith('dist/'))) { + throw new Error('The SDK backport must change emitted files only and preserve published package metadata.'); + } + return value; +} + +function assertRealDirectory(directory: string): void { + const stat = fs.lstatSync(directory, { throwIfNoEntry: false }); + if (stat?.isSymbolicLink()) { + throw new Error(`Refusing to patch a symlinked dependency directory: ${directory}`); + } + if (!stat?.isDirectory()) { + throw new Error(`Missing dependency directory: ${directory}. Restore dependencies with npm ci before applying the SDK backport.`); + } +} + +function packageDirectory(repositoryRoot: string, scope: string): string { + let directory = repositoryRoot; + for (const part of [...(scope ? scope.split('/') : []), 'node_modules', '@github', 'copilot-sdk']) { + directory = path.join(directory, part); + assertRealDirectory(directory); + } + return directory; +} + +function packageHashes(directory: string): Readonly> { + const hashes: Record = {}; + const visit = (relative: string): void => { + for (const entry of fs.readdirSync(path.join(directory, relative), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + if (!relative && entry.name === 'node_modules') { + continue; + } + const file = relative ? `${relative}/${entry.name}` : entry.name; + if (!isPackagePath(file) || entry.isSymbolicLink()) { + throw new Error(`Unexpected SDK package entry: ${file}`); + } + if (entry.isDirectory()) { + visit(file); + } else if (entry.isFile()) { + hashes[file] = sha256(fs.readFileSync(path.join(directory, file))); + } else { + throw new Error(`SDK package entry is not a regular file: ${file}`); + } + } + }; + visit(''); + return hashes; +} + +function matches(actual: Readonly>, expected: Readonly>): boolean { + return Object.keys(actual).length === Object.keys(expected).length + && Object.entries(expected).every(([file, hash]) => actual[file] === hash); +} + +function inspectPackage(directory: string, manifest: CopilotSdkCanvasPatchManifest): PackageState { + const hashes = packageHashes(directory); + if (matches(hashes, manifest.after)) { + return { directory, state: 'after' }; + } + if (matches(hashes, manifest.before)) { + return { directory, state: 'before' }; + } + throw new Error(`Unexpected or partially patched Copilot SDK at ${directory}. The canvas backport requires the exact 1.0.13 package; restore it with npm ci.`); +} + +function gitApply(directory: string, args: readonly string[], patch: Buffer): string { + const env = { ...process.env }; + // Keep git apply outside the enclosing checkout, even in CI with an inherited Git context. + for (const name of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX']) { + delete env[name]; + } + env.GIT_CEILING_DIRECTORIES = path.dirname(directory); + const result = spawnSync('git', ['-c', 'core.autocrlf=false', '-c', 'core.eol=lf', 'apply', ...args, '--whitespace=nowarn', '-'], { + cwd: directory, + env, + input: patch, + encoding: 'utf8', + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error(`Unable to apply the generated Copilot SDK delta: ${result.stderr.trim() || `git exited with ${result.status}`}`); + } + return result.stdout; +} + +function validateDelta(directory: string, manifest: CopilotSdkCanvasPatchManifest, patch: Buffer): void { + if (/^(?:(?:new file mode|old mode|new mode) (?:120000|160000)|(?:rename|copy) (?:from|to) )/m.test(patch.toString('utf8'))) { + throw new Error('SDK backport deltas cannot contain symlinks, submodules, renames or copies.'); + } + const expected = new Set(Object.keys(manifest.after).filter(file => manifest.before[file] !== manifest.after[file])); + const records = gitApply(directory, ['--numstat', '-z'], patch).split('\0'); + if (records.pop() !== '') { + throw new Error('Invalid SDK backport file statistics.'); + } + const actual = new Set(); + for (const record of records) { + const [added, removed, file, extra] = record.split('\t'); + if (extra !== undefined || !/^\d+$/.test(added) || !/^\d+$/.test(removed) || !expected.has(file) || actual.has(file)) { + throw new Error('The SDK delta contains an unexpected file, binary change or duplicate file patch.'); + } + actual.add(file); + } + if (actual.size !== expected.size) { + throw new Error('The SDK delta does not cover the complete declared file changes.'); + } +} + +function cleanUpAndThrow(directory: string, error: unknown, fileOperations: Pick): never { + try { + fileOperations.rmSync(directory, { recursive: true, force: true }); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], `SDK backport failed and staging cleanup also failed: ${directory}`); + } + throw error; +} + +function patchPackage(directory: string, manifest: CopilotSdkCanvasPatchManifest, patch: Buffer, fileOperations: Pick): void { + const staging = fs.mkdtempSync(path.join(path.dirname(directory), '.copilot-sdk-canvas-')); + const candidate = path.join(staging, 'package'); + const backup = path.join(staging, 'original'); + let movedOriginal = false; + try { + fs.cpSync(directory, candidate, { recursive: true, dereference: false, verbatimSymlinks: true, force: false, errorOnExist: true }); + assertRealDirectory(candidate); + if (!matches(packageHashes(candidate), manifest.before)) { + throw new Error('The SDK package changed while it was being copied.'); + } + gitApply(candidate, [], patch); + if (!matches(packageHashes(candidate), manifest.after)) { + throw new Error('The generated SDK delta did not produce the expected complete package.'); + } + if (!matches(packageHashes(directory), manifest.before)) { + throw new Error('The SDK package changed while the backport was being prepared.'); + } + fileOperations.renameSync(directory, backup); + movedOriginal = true; + fileOperations.renameSync(candidate, directory); + } catch (error) { + if (movedOriginal) { + try { + fileOperations.renameSync(backup, directory); + } catch (rollbackError) { + // Never remove the original package if restoration failed. + throw new AggregateError([error, rollbackError], `SDK replacement failed. The original package is retained at ${backup}.`); + } + } + cleanUpAndThrow(staging, error, fileOperations); + } + fileOperations.rmSync(staging, { recursive: true, force: true }); +} + +function hasDistroRemote(repositoryRoot: string): boolean { + let directory = repositoryRoot; + for (const part of ['.build', 'distro', 'npm', 'remote']) { + directory = path.join(directory, part); + if (!fs.lstatSync(directory, { throwIfNoEntry: false })) { + return false; + } + assertRealDirectory(directory); + } + return true; +} + +/** + * Verifies or installs the source-built SDK backport in the workbench and remote dependencies. + * All targets must match a complete before/after image before any package is replaced. + */ +export function ensureCopilotSdkCanvasPatch( + repositoryRoot: string = root, + options: CopilotSdkCanvasPatchOptions = {}, +): readonly PatchResult[] { + assertRealDirectory(repositoryRoot); + const canonicalRoot = fs.realpathSync.native(repositoryRoot); + const manifestPath = options.manifestPath ?? path.join(canonicalRoot, 'build', 'npm', 'copilot-sdk-canvas.json'); + const manifest = readManifest(manifestPath); + const patch = fs.readFileSync(path.join(path.dirname(manifestPath), manifest.patchFile)); + if (sha256(patch) !== manifest.patchSha256) { + throw new Error('The Copilot SDK canvas delta does not match its recorded SHA-256.'); + } + validateDelta(canonicalRoot, manifest, patch); + const scopes = ['', 'remote']; + if (hasDistroRemote(canonicalRoot)) { + scopes.push('.build/distro/npm/remote'); + } + const packages = scopes.map(scope => inspectPackage(packageDirectory(canonicalRoot, scope), manifest)); + if (options.checkOnly && packages.some(item => item.state !== 'after')) { + throw new Error('The Copilot SDK canvas backport is not installed. Run npm run copilot:patch-sdk.'); + } + return packages.map((item): PatchResult => { + if (item.state === 'after') { + return { directory: item.directory, status: 'verified' }; + } + patchPackage(item.directory, manifest, patch, options.fileOperations ?? fs); + return { directory: item.directory, status: 'applied' }; + }); +} + +if (import.meta.filename === process.argv[1]) { + const args = process.argv.slice(2); + if (args.some(arg => arg !== '--check')) { + throw new Error('Usage: node build/npm/copilotSdkCanvasPatch.ts [--check]'); + } + for (const result of ensureCopilotSdkCanvasPatch(root, { checkOnly: args.includes('--check') })) { + console.log(`[${path.relative(root, result.directory)}] Copilot SDK canvas backport ${result.status}`); + } +} diff --git a/build/npm/fast-install.ts b/build/npm/fast-install.ts index ff9a7d2097cf2..79463148ac0a2 100644 --- a/build/npm/fast-install.ts +++ b/build/npm/fast-install.ts @@ -5,8 +5,10 @@ import * as child_process from 'child_process'; import { root, isUpToDate, forceInstallMessage } from './installStateHash.ts'; +import { ensureCopilotSdkCanvasPatch } from './copilotSdkCanvasPatch.ts'; if (!process.argv.includes('--force') && isUpToDate()) { + ensureCopilotSdkCanvasPatch(root); console.log(`\x1b[32mAll dependencies up to date.\x1b[0m ${forceInstallMessage}`); process.exit(0); } diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index 0b3d9898015d6..ac4747b18665e 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -13,11 +13,20 @@ export const stateFile = path.join(root, 'node_modules', '.postinstall-state'); export const stateContentsFile = path.join(root, 'node_modules', '.postinstall-state-contents'); export const forceInstallMessage = 'Run \x1b[36mnode build/npm/fast-install.ts --force\x1b[0m to force a full install.'; -export function collectInputFiles(): string[] { +export const postinstallInputFiles: readonly string[] = [ + 'build/npm/postinstall.ts', + 'build/npm/fast-install.ts', + 'build/npm/installStateHash.ts', + 'build/npm/copilotSdkCanvasPatch.ts', + 'build/npm/copilot-sdk-canvas.json', + 'build/npm/copilot-sdk-canvas.patch', +]; + +export function collectInputFiles(repositoryRoot: string = root): string[] { const files: string[] = []; for (const dir of dirs) { - const base = dir === '' ? root : path.join(root, dir); + const base = dir === '' ? repositoryRoot : path.join(repositoryRoot, dir); for (const file of ['package.json', 'package-lock.json', '.npmrc']) { const filePath = path.join(base, file); if (fs.existsSync(filePath)) { @@ -26,7 +35,10 @@ export function collectInputFiles(): string[] { } } - files.push(path.join(root, '.nvmrc')); + files.push(path.join(repositoryRoot, '.nvmrc')); + for (const file of postinstallInputFiles) { + files.push(path.join(repositoryRoot, file)); + } return files; } @@ -87,10 +99,11 @@ function hashContent(content: string): string { return hash.digest('hex'); } -export function computeState(options?: { ignoreNodeVersion?: boolean }): PostinstallState { +export function computeState(options?: { ignoreNodeVersion?: boolean; repositoryRoot?: string }): PostinstallState { + const repositoryRoot = options?.repositoryRoot ?? root; const fileHashes: Record = {}; - for (const filePath of collectInputFiles()) { - const key = path.relative(root, filePath); + for (const filePath of collectInputFiles(repositoryRoot)) { + const key = path.relative(repositoryRoot, filePath); try { fileHashes[key] = hashContent(normalizeFileContent(filePath)); } catch { diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index 774df8476dc5e..3dff5842dcfb0 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -10,6 +10,7 @@ import * as child_process from 'child_process'; import { dirs } from './dirs.ts'; import { root, stateFile, stateContentsFile, computeState, computeContents, isUpToDate } from './installStateHash.ts'; import { ensureElectronTypes } from './electronTypes.ts'; +import { ensureCopilotSdkCanvasPatch } from './copilotSdkCanvasPatch.ts'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const rootNpmrcConfigKeys = getNpmrcConfigKeys(path.join(root, '.npmrc')); @@ -242,6 +243,7 @@ async function main() { await ensureElectronTypes(); if (!process.env['VSCODE_FORCE_INSTALL'] && isUpToDate()) { + ensureCopilotSdkCanvasPatch(root); log('.', 'All dependencies up to date, skipping postinstall.'); child_process.execSync('git config pull.rebase merges'); child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); @@ -319,9 +321,6 @@ async function main() { child_process.execSync('git config pull.rebase merges'); child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); - fs.writeFileSync(stateFile, JSON.stringify(_state)); - fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); - // Symlink .claude/ files to their canonical locations to test Claude agent harness const claudeDir = path.join(root, '.claude'); fs.mkdirSync(claudeDir, { recursive: true }); @@ -353,6 +352,8 @@ async function main() { } } + ensureCopilotSdkCanvasPatch(root); + // foundry-local-sdk (on-device chat dictation) resolves its prebuilt N-API // addon and native core libraries from fixed, package-relative paths. We do // not ship that native payload (its addon requires a newer glibc than our @@ -399,6 +400,9 @@ async function main() { log(dir || '.', 'Patched foundry-local-sdk coreInterop.js (on-demand native runtime override)'); } } + + fs.writeFileSync(stateFile, JSON.stringify(_state)); + fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); } main().catch(err => { diff --git a/package.json b/package.json index 94ae42b834842..8995bc6b24881 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "check-cyclic-dependencies": "node build/lib/checkCyclicDependencies.ts out", "preinstall": "node build/npm/preinstall.ts", "postinstall": "node build/npm/postinstall.ts", + "copilot:patch-sdk": "node build/npm/copilotSdkCanvasPatch.ts", "compile": "npm-run-all2 -lp compile-client compile-copilot", "compile-client": "npm run gulp compile", "compile-copilot": "npm --prefix extensions/copilot run compile", diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 85b9c1578d75b..29cda95c058e8 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -53,6 +53,7 @@ export const enum AccessibleViewProviderId { Survey = 'survey', Automations = 'automations', BrowserElementCommenting = 'browserElementCommenting', + SessionCanvas = 'sessionCanvas', ChatPetAchievements = 'chatPetAchievements', } diff --git a/src/vs/platform/accessibility/browser/accessibleViewRegistry.ts b/src/vs/platform/accessibility/browser/accessibleViewRegistry.ts index 2f98c85f893db..e327d229e9cb5 100644 --- a/src/vs/platform/accessibility/browser/accessibleViewRegistry.ts +++ b/src/vs/platform/accessibility/browser/accessibleViewRegistry.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IDisposable } from '../../../base/common/lifecycle.js'; +import { Emitter } from '../../../base/common/event.js'; +import { Disposable, IDisposable, markAsSingleton, toDisposable } from '../../../base/common/lifecycle.js'; import { AccessibleViewType, AccessibleContentProvider, ExtensionContentProvider } from './accessibleView.js'; import { ContextKeyExpression } from '../../contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../instantiation/common/instantiation.js'; @@ -19,23 +20,26 @@ export interface IAccessibleViewImplementation { when?: ContextKeyExpression | undefined; } -export const AccessibleViewRegistry = new class AccessibleViewRegistry { +class AccessibleViewRegistryImpl extends Disposable { _implementations: IAccessibleViewImplementation[] = []; + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; register(implementation: IAccessibleViewImplementation): IDisposable { this._implementations.push(implementation); - return { - dispose: () => { - const idx = this._implementations.indexOf(implementation); - if (idx !== -1) { - this._implementations.splice(idx, 1); - } + this._onDidChange.fire(); + return toDisposable(() => { + const idx = this._implementations.indexOf(implementation); + if (idx !== -1) { + this._implementations.splice(idx, 1); + this._onDidChange.fire(); } - }; + }); } getImplementations(): IAccessibleViewImplementation[] { return this._implementations; } -}; +} +export const AccessibleViewRegistry = markAsSingleton(new AccessibleViewRegistryImpl()); diff --git a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts index 6566e7253d0f1..55f43a3a87f5e 100644 --- a/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/agentHostProtocolClient.ts @@ -8,7 +8,8 @@ import { DeferredPromise, TimeoutTimer } from '../../../base/common/async.js'; import { CancellationError } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, MutableDisposable, IReference } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, MutableDisposable, IReference, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { Schemas } from '../../../base/common/network.js'; import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; @@ -19,7 +20,7 @@ import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../. import { ConfigurationTarget, ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatRequestOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap } from '../common/agentHostExtensionProtocol.js'; +import { CancelAgentHostCanvasApprovalExtensionMethod, CancelCanvasChatInitializationExtensionMethod, ClaimAgentHostDetachedWorktreeExtensionMethod, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, InitializeCanvasChatExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, RequestAgentHostCanvasApprovalExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, supportsAgentHostCanvasChatInitialization, supportsAgentHostChatStateFile, type IAgentHostExtensionCommandMap, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type InitializeCanvasChatParams } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -27,7 +28,7 @@ import { AGENT_HOST_SCHEME, agentHostAuthority, createAgentHostResourceUriMapper import { AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../common/agentHostResourceService.js'; import type { ClientNotificationMap, CommandMap, JsonRpcErrorResponse, JsonRpcRequest, JsonRpcResponse } from '../common/state/protocol/messages.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type INotification, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../common/state/sessionActions.js'; -import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; +import { MessageAttachmentKind, SessionSummary, ROOT_STATE_URI, StateComponents, isAhpRootChannel, isDefaultChatUri, parseChatUri, type ClientPluginCustomization, type Message, type RootState } from '../common/state/sessionState.js'; import { normalizeLegacyActionEnvelope } from '../common/state/legacyProtocolCompatibility.js'; import { SUPPORTED_PROTOCOL_VERSIONS } from '../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, ProtocolError, ReconnectResultType, type ProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -36,6 +37,7 @@ import { isClientTransport, NonReconnectableTransportError, type AgentHostTransp import { AhpErrorCodes, JsonRpcErrorCodes } from '../common/state/protocol/errors.js'; import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import type { CloseCanvasParams, InvokeCanvasActionParams, InvokeCanvasActionResult, ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, ResolveCanvasSourceResult, RestartCanvasProviderParams } from '../common/state/protocol/channels-canvas/commands.js'; import { decodeBase64, encodeBase64 } from '../../../base/common/buffer.js'; import { getExpirationTime, getRemainingTimeInSeconds, isExpired } from '../../../base/common/date.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; @@ -56,11 +58,21 @@ import { computeReconnectDelay, DEFAULT_RECONNECT_POLICY, hasExhaustedReconnectA import type { IRemoteAgentHostProtocolClient } from '../common/remoteAgentHostService.js'; import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../workspace/common/workspaceTrust.js'; import { isWorktreeUnderRepository } from '../common/worktreePaths.js'; +import { IDialogService } from '../../dialogs/common/dialogs.js'; +import { isCanvasRecord } from '../common/agentHostCanvasValidation.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; // AHP 0.9 changed the automation catalog wire shape, so VS Code cannot safely negotiate 0.8. const CLIENT_SUPPORTED_PROTOCOL_VERSIONS = SUPPORTED_PROTOCOL_VERSIONS.filter(version => version !== '0.8.0'); +function canvasApprovalCancellation(message: unknown): string | undefined { + if (isCanvasRecord(message) && message.jsonrpc === '2.0' && message.method === CancelAgentHostCanvasApprovalExtensionMethod + && !Object.hasOwn(message, 'id') && isCanvasRecord(message.params) && typeof message.params.requestId === 'string') { + return message.params.requestId; + } + return undefined; +} + /** * After this much inbound silence, send an application-level `ping` to * the remote so we have something to time out on. Reset on every received @@ -262,6 +274,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect private readonly _onDidChangeConnectionState = this._register(new Emitter()); readonly onDidChangeConnectionState = this._onDidChangeConnectionState.event; + private readonly _canvasApprovals = this._register(new DisposableMap()); private readonly _onDidScheduleReconnect = this._register(new Emitter()); readonly onDidScheduleReconnect = this._onDidScheduleReconnect.event; @@ -407,8 +420,14 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect @IWorkspaceTrustEnablementService private readonly _workspaceTrustEnablementService: IWorkspaceTrustEnablementService, @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IDialogService private readonly _dialogService: IDialogService, ) { super(); + this._register(this.onDidChangeConnectionState(state => { + if (state !== AgentHostClientState.Connected) { + this._canvasApprovals.clearAndDisposeAll(); + } + })); this._resourceIdentity = identity; this._address = identity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY ? AMBIENT_AGENT_HOST_AUTHORITY : identity; this._clientId = options?.clientId ?? generateUuid(); @@ -570,6 +589,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect // older host (a cloud sandbox running a 0.5.x `copilotd`) can negotiate down // instead of rejecting the connection. A current host still picks the newest. protocolVersions: [...CLIENT_SUPPORTED_PROTOCOL_VERSIONS], + capabilities: { canvases: {} }, clientId: this._clientId, clientInfo: this._clientInfo, _meta: this._clientMeta(), @@ -909,6 +929,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect const initializeResult = await this._dispatchRequest('initialize', { channel: ROOT_STATE_URI, protocolVersions: [...CLIENT_SUPPORTED_PROTOCOL_VERSIONS], + capabilities: { canvases: {} }, clientId: this._clientId, clientInfo: this._clientInfo, _meta: this._clientMeta(), @@ -1631,6 +1652,69 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect return await this._sendRequest('invokeChangesetOperation', params); } + async listCanvasTypes(params: ListCanvasTypesParams): Promise { + this._assertCanvasConnection(); + return this._sendRequest('listCanvasTypes', params); + } + + async initializeCanvasChat(params: InitializeCanvasChatParams, token: CancellationToken = CancellationToken.None): Promise { + this._assertCanvasConnection(); + if (!supportsAgentHostCanvasChatInitialization(this.initializeResult.get())) { + throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Canvas chat initialization was not negotiated.'); + } + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const connection = this._state; + const request = this._sendExtensionRequest(InitializeCanvasChatExtensionMethod, params); + const cancellation = token.onCancellationRequested(() => { + if (this._state === connection && this._state.kind === AgentHostClientState.Connected) { + void this._sendExtensionRequest(CancelCanvasChatInitializationExtensionMethod, params).catch(error => { + this._logService.warn('[AgentHost] Canvas initialization cancellation was not acknowledged', error); + }); + } + }); + try { + await request; + } finally { + cancellation.dispose(); + } + } + + async openCanvas(params: OpenCanvasParams): Promise { + this._assertCanvasConnection(); + return this._sendRequest('openCanvas', params); + } + + async resolveCanvasSource(params: ResolveCanvasSourceParams): Promise { + this._assertCanvasConnection(); + return this._sendRequest('resolveCanvasSource', params); + } + + async invokeCanvasAction(params: InvokeCanvasActionParams): Promise { + this._assertCanvasConnection(); + return this._sendRequest('invokeCanvasAction', params); + } + + async restartCanvasProvider(params: RestartCanvasProviderParams): Promise { + this._assertCanvasConnection(); + await this._sendRequest('restartCanvasProvider', params); + } + + async closeCanvas(params: CloseCanvasParams): Promise { + this._assertCanvasConnection(); + await this._sendRequest('closeCanvas', params); + } + + private _assertCanvasConnection(): void { + if (this._state.kind !== AgentHostClientState.Connected) { + throw new ProtocolError(AHP_CLIENT_CONNECTION_CLOSED, 'Canvas requests require a live connection and are never queued for replay.'); + } + if (!this._initializeResult.get()?.canvases) { + throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Canvas support was not negotiated.'); + } + } + /** * Send a request on an `mcp://` AHP side channel. The agent-host * routes by `params.channel` so we inject it automatically. @@ -1816,7 +1900,10 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect this._lastReadTime = Date.now(); this._resetLivenessTimers(); - if (isJsonRpcRequest(msg)) { + const cancelledApproval = canvasApprovalCancellation(msg); + if (cancelledApproval !== undefined) { + this._canvasApprovals.deleteAndDispose(cancelledApproval); + } else if (isJsonRpcRequest(msg)) { this._handleReverseRequest(msg.id, msg.method, msg.params); } else if (isJsonRpcResponse(msg)) { const pending = this._pendingRequests.get(msg.id); @@ -1998,6 +2085,29 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect void (async () => { try { switch (method) { + case RequestAgentHostCanvasApprovalExtensionMethod: { + if (typeof p.requestId !== 'string' || !p.requestId.length || p.requestId.length > 256 || this._canvasApprovals.has(p.requestId) + || typeof p.chat !== 'string' || p.chat.length > 8192 || !parseChatUri(p.chat) + || typeof p.message !== 'string' || !p.message.length || p.message.length > 16384 || this._canvasApprovals.size >= 32) { + throw new Error('Invalid canvas approval request.'); + } + const cancellation = new CancellationTokenSource(); + this._canvasApprovals.set(p.requestId, toDisposable(() => cancellation.dispose(true))); + try { + const result = await this._dialogService.confirm({ + type: 'warning', title: localize('canvas.sourcePermission', "Canvas Permission"), + message: p.message, + detail: localize('canvas.sourcePermissionDetail', "This permission applies to chat {0} on agent host {1}. It is not a Workspace Trust grant.", p.chat, this._address), + primaryButton: localize('canvas.allowSource', "Allow"), + cancelButton: localize('canvas.denySource', "Don't Allow"), + custom: true, token: cancellation.token, + }); + sendResult({ requestId: p.requestId, approved: result.confirmed && !cancellation.token.isCancellationRequested } satisfies IAgentHostExtensionServerCommandMap[typeof RequestAgentHostCanvasApprovalExtensionMethod]['result']); + } finally { + this._canvasApprovals.deleteAndDispose(p.requestId); + } + break; + } case RequestAgentHostWorkspaceTrustExtensionMethod: { if (typeof p.workspace !== 'string') { throw new Error('Missing workspace'); @@ -2211,7 +2321,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect throw this._state.error; } const { request, result } = this._createRequest(method, params); - this._transport.send(request); + this._writeRequest(request); return result; } if (!options.bypassInitializeQueue && isClientTransport(this._transport) && this._state.kind === AgentHostClientState.Connecting) { @@ -2247,10 +2357,20 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect } const { request, result } = this._createRequest(method, params); - this._transport.send(request); + this._writeRequest(request); return result; } + private _writeRequest(request: JsonRpcRequest): void { + try { + this._transport.send(request); + } catch (error) { + const pending = this._pendingRequests.get(request.id); + this._pendingRequests.delete(request.id); + pending?.deferred.error(error); + } + } + private _createRequest(method: string, params: unknown): { request: JsonRpcRequest; result: Promise } { const id = this._nextRequestId++; const deferred = new DeferredPromise(); diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 0bb72e422be3d..463ac6de879d0 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import type { CancellationToken } from '../../../base/common/cancellation.js'; +import type { InitializeCanvasChatParams } from '../common/agentHostExtensionProtocol.js'; import { IReference } from '../../../base/common/lifecycle.js'; import { constObservable, IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; @@ -13,6 +15,7 @@ import type { IActiveSubscriptionInfo, IAgentSubscription } from '../common/stat import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import type { CloseCanvasParams, InvokeCanvasActionParams, InvokeCanvasActionResult, ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, ResolveCanvasSourceResult, RestartCanvasProviderParams } from '../common/state/protocol/channels-canvas/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientAutomationAction, ClientAutomationRunAction, ClientChangesetAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; import type { IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; @@ -82,6 +85,13 @@ export class NullAgentHostService implements IAgentHostService { async createTerminal(_params: CreateTerminalParams): Promise { notSupported(); } async disposeTerminal(_terminal: URI): Promise { } async invokeChangesetOperation(_params: InvokeChangesetOperationParams): Promise { return notSupported(); } + async listCanvasTypes(_params: ListCanvasTypesParams): Promise { return notSupported(); } + async initializeCanvasChat(_params: InitializeCanvasChatParams, _token?: CancellationToken): Promise { return notSupported(); } + async openCanvas(_params: OpenCanvasParams): Promise { return notSupported(); } + async resolveCanvasSource(_params: ResolveCanvasSourceParams): Promise { return notSupported(); } + async invokeCanvasAction(_params: InvokeCanvasActionParams): Promise { return notSupported(); } + async restartCanvasProvider(_params: RestartCanvasProviderParams): Promise { return notSupported(); } + async closeCanvas(_params: CloseCanvasParams): Promise { return notSupported(); } async handleMcpRequest(_channel: string, _method: string, _params: Record | undefined): Promise { return notSupported(); } async resourceList(_uri: URI): Promise { return notSupported(); } async resourceRead(_uri: URI): Promise { return notSupported(); } diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 85e3e8f3b680f..02fdd1da34755 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -13,6 +13,7 @@ import type { IObservable } from '../../../base/common/observable.js'; import { isEqual } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import type { IAgentServerToolHost } from './agentServerTools.js'; +import type { IAgentCanvases } from './agentHostCanvases.js'; import type { AgentHostClientType } from './agentHostClientInfo.js'; import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; @@ -801,7 +802,7 @@ export interface IAgentChats { resumeTurn?(chat: URI, turnId: string, context: AgentChatOperationContext, senderClientId?: string, clientType?: AgentHostClientType): Promise; /** Abort the in-flight turn for `chat`. */ - abort(chat: URI, context: AgentChatOperationContext): Promise; + abort(chat: URI, context: AgentChatOperationContext, turnId?: string): Promise; /** Return the model currently bound to `chat`, when the provider knows it. */ getModel?(chat: URI, context: AgentChatOperationContext): ModelSelection | undefined; @@ -1167,6 +1168,7 @@ export interface IAgentChatAdoptionResult { * the agent id. */ export interface IAgent { + readonly canvases?: IAgentCanvases; // ---- Identity and catalog ----------------------------------------------- /** Unique provider identifier. */ @@ -1239,10 +1241,10 @@ export interface IAgent { onClientToolCallComplete(chat: URI, toolCallId: string, result: ToolCallResult, context?: IAgentChatContext): void; /** Respond to a pending permission request from the SDK. */ - respondToPermissionRequest(requestId: string, approved: boolean): void; + respondToPermissionRequest(requestId: string, approved: boolean, chat?: URI): void; /** Respond to a pending user input request from the SDK's ask_user tool. */ - respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): void; + respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record, chat?: URI): void; // ---- Configuration and customizations ---------------------------------- diff --git a/src/vs/platform/agentHost/common/agentHostCanvasValidation.ts b/src/vs/platform/agentHost/common/agentHostCanvasValidation.ts new file mode 100644 index 0000000000000..c8bc2f994f76c --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostCanvasValidation.ts @@ -0,0 +1,278 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { CANVAS_IDENTITY_FIELD_MAX_LENGTH, CANVAS_INPUT_MAX_LENGTH, CANVAS_MAX_DECLARED_ACTIONS, CANVAS_REQUEST_ID_MAX_LENGTH, CANVAS_SCHEMA_MAX_DEPTH, CANVAS_SCHEMA_MAX_PROPERTIES, CanvasSourceKind, type CanvasActionDeclaration, type CanvasEntry, type CanvasIdentityKey, type CanvasSource, type CanvasState, type CanvasTypeDeclaration } from './state/protocol/channels-canvas/state.js'; +import { JsonRpcErrorCodes, ProtocolError } from './state/sessionProtocol.js'; +import { parseChatUri } from './state/sessionState.js'; +import type { Icon } from './state/protocol/common/state.js'; + +export const AHP_CANVAS_SCHEME = 'ahp-canvas'; +export type CanvasMethod = 'listCanvasTypes' | 'openCanvas' | 'resolveCanvasSource' | 'invokeCanvasAction' | 'restartCanvasProvider' | 'closeCanvas'; + +export function isCanvasMethod(method: string): method is CanvasMethod { + return method === 'listCanvasTypes' || method === 'openCanvas' || method === 'resolveCanvasSource' + || method === 'invokeCanvasAction' || method === 'restartCanvasProvider' || method === 'closeCanvas'; +} + +export function isCanvasRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isCanvasResource(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 2048) { + return false; + } + try { + const uri = URI.parse(value); + return uri.scheme === AHP_CANVAS_SCHEME && uri.path.length > 1 && !uri.authority && !uri.query && !uri.fragment; + } catch { + return false; + } +} + +function isIdentityField(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= CANVAS_IDENTITY_FIELD_MAX_LENGTH; +} + +export function isCanvasSource(value: unknown): value is CanvasSource { + return isCanvasRecord(value) && (value.kind === CanvasSourceKind.Extension + ? isIdentityField(value.extensionId) + : value.kind === CanvasSourceKind.Package && isIdentityField(value.sourceId) && typeof value.packageName === 'string' && value.packageName.length <= 256) + && (value.version === undefined || typeof value.version === 'string' && value.version.length <= 256); +} + +export function isCanvasIdentity(value: unknown): value is CanvasIdentityKey { + return isCanvasRecord(value) && typeof value.chat === 'string' && value.chat.length <= 8192 + && isCanvasChat(value.chat) && isCanvasSource(value.source) + && isIdentityField(value.canvasType) && isIdentityField(value.instanceId); +} + +function isCanvasChat(value: string): boolean { + try { + return !!parseChatUri(value); + } catch { + return false; + } +} + +export function isCanvasIcon(value: unknown): value is Icon { + if (!isBoundedCanvasJson(value, 4096) || !isCanvasRecord(value) || typeof value.src !== 'string' + || value.contentType !== undefined && typeof value.contentType !== 'string' + || value.theme !== undefined && value.theme !== 'dark' && value.theme !== 'light' + || value.sizes !== undefined && (!Array.isArray(value.sizes) || !value.sizes.every(size => typeof size === 'string' && /^(?:any|[1-9][0-9]*x[1-9][0-9]*)$/.test(size)))) { + return false; + } + try { + const uri = URI.parse(value.src); + return (uri.scheme === 'file' && uri.path.startsWith('/') && !uri.authority && !uri.query && !uri.fragment) || /^data:image\/(?:png|jpeg|gif|webp);base64,[A-Za-z0-9+/=]+$/.test(value.src); + } catch { + return false; + } +} + +export function canvasSourceKey(source: CanvasSource): string { + return JSON.stringify(source.kind === CanvasSourceKind.Extension ? [source.kind, source.extensionId] : [source.kind, source.sourceId]); +} + +export function canvasIdentityKey(identity: CanvasIdentityKey): string { + return JSON.stringify([identity.chat, canvasSourceKey(identity.source), identity.canvasType, identity.instanceId]); +} + +export function canvasEntry(state: CanvasState): CanvasEntry { + return { + resource: state.resource, identity: state.identity, title: state.title, + ...(state.icon === undefined ? {} : { icon: state.icon }), + trust: state.trust, availability: state.availability.status, revision: state.revision, + }; +} + +/** Rejects non-JSON values without invoking getters, custom prototypes, or toJSON. */ +export function isBoundedCanvasJson(value: unknown, maxLength = CANVAS_INPUT_MAX_LENGTH): boolean { + let nodes = 0; + const ancestors = new Set(); + const visit = (candidate: unknown, depth: number): boolean => { + if (++nodes > maxLength || depth > 64) { + return false; + } + if (candidate === null || typeof candidate === 'boolean') { + return true; + } + if (typeof candidate === 'string') { + return candidate.length <= maxLength; + } + if (typeof candidate === 'number') { + return Number.isFinite(candidate); + } + if (typeof candidate !== 'object' || ancestors.has(candidate)) { + return false; + } + const array = Array.isArray(candidate); + const prototype = Object.getPrototypeOf(candidate); + if (prototype !== (array ? Array.prototype : Object.prototype) && (array || prototype !== null)) { + return false; + } + if (Object.getOwnPropertySymbols(candidate).length) { + return false; + } + ancestors.add(candidate); + try { + const keys = Object.getOwnPropertyNames(candidate); + if (array && keys.length !== candidate.length + 1) { + return false; + } + for (const key of keys) { + if (array && key === 'length') { + continue; + } + if (array && (String(Number(key)) !== key || !Number.isSafeInteger(Number(key)) || Number(key) < 0 || Number(key) >= candidate.length)) { + return false; + } + const property = Object.getOwnPropertyDescriptor(candidate, key); + if (key.length > maxLength || !property || !Object.hasOwn(property, 'value') || !property.enumerable || !visit(property.value, depth + 1)) { + return false; + } + } + return true; + } finally { + ancestors.delete(candidate); + } + }; + try { + return visit(value, 1) && JSON.stringify(value).length <= maxLength; + } catch { + return false; + } +} + +export function validateCanvasRequest(method: CanvasMethod, params: unknown): void { + if (!isCanvasRecord(params) || typeof params.channel !== 'string' || params.channel.length > 8192) { + throw invalidCanvasParams(); + } + if (params.input !== undefined && !isBoundedCanvasJson(params.input)) { + throw invalidCanvasParams('Canvas input must be bounded JSON.'); + } + if (method === 'listCanvasTypes') { + if (!isCanvasChat(params.channel) || params.limit !== undefined && (!Number.isSafeInteger(params.limit) || typeof params.limit !== 'number' || params.limit < 1 || params.limit > 64) + || params.cursor !== undefined && (typeof params.cursor !== 'string' || params.cursor.length > 256)) { + throw invalidCanvasParams(); + } + return; + } + if (method !== 'resolveCanvasSource' && (typeof params.requestId !== 'string' || !params.requestId.length || params.requestId.length > CANVAS_REQUEST_ID_MAX_LENGTH)) { + throw invalidCanvasParams(); + } + if (method === 'openCanvas') { + if (!isCanvasResource(params.canvas) || !isCanvasIdentity(params.identity) || parseChatUri(params.identity.chat)?.session !== params.channel + || typeof params.title !== 'string' || params.title.length > 4096 || params.icon !== undefined && !isCanvasIcon(params.icon)) { + throw invalidCanvasParams(); + } + return; + } + if (!isCanvasResource(params.channel)) { + throw invalidCanvasParams(); + } + if ((method === 'invokeCanvasAction' || method === 'restartCanvasProvider') && !isIdentityField(params.incarnation)) { + throw invalidCanvasParams(); + } + if (method === 'invokeCanvasAction' && !isIdentityField(params.actionId)) { + throw invalidCanvasParams(); + } + if (method === 'closeCanvas' && (typeof params.revision !== 'number' || !Number.isSafeInteger(params.revision) || params.revision < 0)) { + throw invalidCanvasParams(); + } +} + +/** Counts schema-bearing nesting, including combinators and local references, rather than only properties. */ +export function isInlineCanvasSchema(schema: unknown): schema is NonNullable { + if (!isBoundedCanvasJson(schema) || !isCanvasRecord(schema) || schema.type !== 'object') { + return false; + } + const active = new Set(); + const visit = (value: unknown, depth: number): boolean => { + if (typeof value === 'boolean') { + return depth <= CANVAS_SCHEMA_MAX_DEPTH; + } + if (!isCanvasRecord(value) || depth > CANVAS_SCHEMA_MAX_DEPTH || active.has(value)) { + return false; + } + active.add(value); + try { + if (value.required !== undefined && (!Array.isArray(value.required) || !value.required.every(entry => typeof entry === 'string') || new Set(value.required).size !== value.required.length)) { + return false; + } + for (const [key, child] of Object.entries(value)) { + if (key === '$ref') { + if (typeof child !== 'string' || !child.startsWith('#/')) { + return false; + } + let target: unknown = schema; + for (const part of child.slice(2).split('/')) { + const decoded = part.replace(/~1/g, '/').replace(/~0/g, '~'); + target = isCanvasRecord(target) && Object.hasOwn(target, decoded) ? target[decoded] : undefined; + } + if (!visit(target, depth)) { + return false; + } + } else if (key === 'properties' || key === 'patternProperties' || key === '$defs' || key === 'definitions' || key === 'dependentSchemas') { + if (!isCanvasRecord(child) || Object.keys(child).length > CANVAS_SCHEMA_MAX_PROPERTIES || !Object.values(child).every(entry => visit(entry, depth + 1))) { + return false; + } + } else if (key === 'dependencies') { + if (!isCanvasRecord(child) || Object.keys(child).length > CANVAS_SCHEMA_MAX_PROPERTIES + || !Object.values(child).every(entry => Array.isArray(entry) ? entry.every(item => typeof item === 'string') : visit(entry, depth + 1))) { + return false; + } + } else if (['allOf', 'anyOf', 'oneOf', 'prefixItems'].includes(key) || key === 'items' && Array.isArray(child)) { + if (!Array.isArray(child) || !child.every(entry => visit(entry, depth + 1))) { + return false; + } + } else if (['items', 'additionalItems', 'additionalProperties', 'contains', 'not', 'if', 'then', 'else', 'propertyNames', 'unevaluatedProperties', 'unevaluatedItems', 'contentSchema'].includes(key) && !visit(child, depth + 1)) { + return false; + } + } + return true; + } finally { + active.delete(value); + } + }; + return visit(schema, 1); +} + +export function validateCanvasActions(actions: readonly CanvasActionDeclaration[]): void { + if (!Array.isArray(actions) || actions.length > CANVAS_MAX_DECLARED_ACTIONS || !actions.every(isCanvasRecord) || new Set(actions.map(action => action.id)).size !== actions.length) { + throw invalidCanvasParams('The provider action declarations exceed the canvas contract.'); + } + for (const action of actions) { + if (!isIdentityField(action.id) || action.title !== undefined && (typeof action.title !== 'string' || action.title.length > 4096) + || action.description !== undefined && (typeof action.description !== 'string' || action.description.length > 8192)) { + throw invalidCanvasParams('The provider declared an invalid action.'); + } + validateCanvasSchemaDeclaration(action.inputSchema, action.inputSchemaRef); + } +} + +export function validateCanvasType(type: CanvasTypeDeclaration): void { + if (!isCanvasRecord(type) || !isCanvasSource(type.source) || !isIdentityField(type.canvasType) || typeof type.title !== 'string' || type.title.length > 4096 + || type.icon !== undefined && !isCanvasIcon(type.icon) || type.description !== undefined && (typeof type.description !== 'string' || type.description.length > 8192)) { + throw invalidCanvasParams('The provider declared an invalid canvas type.'); + } + validateCanvasSchemaDeclaration(type.openInputSchema, type.openInputSchemaRef); + if (type.declaredActions !== undefined) { + validateCanvasActions(type.declaredActions); + } +} + +export function validateCanvasSchemaDeclaration(schema: unknown, reference: unknown): void { + if (schema !== undefined && reference !== undefined + || schema !== undefined && !isInlineCanvasSchema(schema) + || reference !== undefined && (typeof reference !== 'string' || reference.length === 0 || reference.length > 2048)) { + throw invalidCanvasParams('Unsupported canvas schema declaration; use a resolvable schema reference for larger schemas.'); + } +} + +export function invalidCanvasParams(message = 'Invalid canvas parameters.'): ProtocolError { + return new ProtocolError(JsonRpcErrorCodes.InvalidParams, message); +} diff --git a/src/vs/platform/agentHost/common/agentHostCanvases.ts b/src/vs/platform/agentHost/common/agentHostCanvases.ts new file mode 100644 index 0000000000000..616b8948468e7 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostCanvases.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../base/common/cancellation.js'; +import type { Event } from '../../../base/common/event.js'; +import type { URI } from '../../../base/common/uri.js'; +import type { IAgentHostCanvasApprovalRequest, InitializeCanvasChatParams } from './agentHostExtensionProtocol.js'; +import type { CloseCanvasParams, InvokeCanvasActionParams, InvokeCanvasActionResult, ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, ResolveCanvasSourceResult, RestartCanvasProviderParams } from './state/protocol/channels-canvas/commands.js'; +import type { CanvasAvailabilityState, CanvasIdentityKey, CanvasSource, CanvasSourcePresentation, CanvasState, CanvasTrustState, CanvasTypeDeclaration } from './state/protocol/channels-canvas/state.js'; +import type { Icon } from './state/protocol/common/state.js'; + +export const CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN = 'external-runtime-participant'; + +/** Canonical canvas commands on an authenticated, negotiated AHP connection. */ +export interface IAgentCanvasConnection { + /** Explicitly initializes this chat's live registry without requiring a canvas identity or creating a turn. */ + initializeCanvasChat(params: InitializeCanvasChatParams, token?: CancellationToken): Promise; + listCanvasTypes(params: ListCanvasTypesParams): Promise; + openCanvas(params: OpenCanvasParams): Promise; + resolveCanvasSource(params: ResolveCanvasSourceParams): Promise; + invokeCanvasAction(params: InvokeCanvasActionParams): Promise; + restartCanvasProvider(params: RestartCanvasProviderParams): Promise; + closeCanvas(params: CloseCanvasParams): Promise; +} + +/** Provider-owned live state, without presentation credentials or host-assigned identity. */ +export interface IAgentCanvasInstance { + readonly identity: CanvasIdentityKey; + /** Opaque endpoint generation inside one backing, when an individual endpoint is replaced. */ + readonly generation?: string; + readonly title: string; + readonly icon?: Icon; + readonly availability: CanvasAvailabilityState; +} + +/** A complete observation of one exact native backing, never the focused conversation. */ +export interface IAgentCanvasSnapshot { + readonly chat: string; + readonly generation: string; + readonly types: readonly CanvasTypeDeclaration[]; + readonly instances: readonly IAgentCanvasInstance[]; + /** Explicit native removals, distinct from an unavailable/missing live endpoint. */ + readonly closed?: readonly CanvasIdentityKey[]; +} + +export interface IAgentCanvasOperation { + readonly clientId?: string; + readonly initiator?: IAgentCanvasApprovalClient; + readonly token: CancellationToken; + readonly workingDirectories?: readonly URI[]; + /** Called immediately before each effect, including executable initialization. */ + willExecute(): void; +} + +/** An authenticated transport's human-approval channel and its connection lifetime. */ +export interface IAgentCanvasApprovalClient { + readonly clientId: string; + readonly token: CancellationToken; + requestApproval(request: IAgentHostCanvasApprovalRequest, token: CancellationToken): Promise; +} + +/** Optional provider facet; reads never create, resume, admit, or restart a backing. */ +export interface IAgentCanvases { + /** Host sends enter synchronized turn state only when this provider observes their actual runtime turn boundary. */ + readonly defersHostTurnStart?: boolean; + /** Native runtimes may reserve an instance ID across an entire chat; otherwise the full canonical identity is used. */ + readonly instanceIdScope?: 'chat'; + /** A real, negotiated runtime; individual source execution still requires admission. */ + readonly available: boolean; + /** Already-started, explicitly opted-in runtime handshake; observing it never starts a runtime. */ + readonly readiness?: Promise; + readonly onDidChange: Event; + getSnapshot(chat: string): IAgentCanvasSnapshot | undefined; + getTrust(chat: string, source: CanvasSource): CanvasTrustState; + /** Resolves only after the exact backing's initial registry is observable. */ + initializeChat(chat: string, operation: IAgentCanvasOperation): Promise; + /** Explicitly effectful canvas-first initialization, only from an open/restart request. */ + prepare?(identity: CanvasIdentityKey, operation: IAgentCanvasOperation): Promise; + open(params: OpenCanvasParams, operation: IAgentCanvasOperation): Promise; + invoke(state: CanvasState, params: InvokeCanvasActionParams, operation: IAgentCanvasOperation): Promise; + close(state: CanvasState, operation: IAgentCanvasOperation): Promise; + restart(state: CanvasState, operation: IAgentCanvasOperation): Promise; + /** Authorizes this client on every pull and returns only an already-live endpoint. */ + resolve(state: CanvasState, clientId: string, token: CancellationToken): Promise; + /** Resolves only provider-owned schema references; no network or filesystem fallback. */ + resolveSchema?(chat: string, source: CanvasSource, reference: string): Promise; + /** Validates input against the current runtime declaration, including referenced schemas. */ + validateInput(chat: string, source: CanvasSource, schema: object, input: unknown): Promise; +} diff --git a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts index 0105a6ebc0118..2a8a895ccb796 100644 --- a/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts +++ b/src/vs/platform/agentHost/common/agentHostChatContributionsService.ts @@ -11,6 +11,7 @@ import type { IAgent } from './agent.js'; import type { AgentHostLaunchKind, AgentHostTurnFailureStage, IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { StateAction } from './state/sessionActions.js'; import type { ErrorInfo, Message, Turn, URI as ProtocolURI } from './state/sessionState.js'; +import type { CanvasState } from './state/protocol/channels-canvas/state.js'; export const IAgentHostChatContributions = createDecorator('agentHostChatContributions'); @@ -72,8 +73,10 @@ export interface IOutgoingTurnContributionResult { readonly message: Message; } -/** A turn request that has entered host state and is asking to proceed to a provider. */ +/** A turn request asking to proceed to a provider. */ export interface IIncomingRequest { + /** A synchronous preflight before runtime initialization; no turn has been admitted and handlers must not execute local commands. */ + readonly phase?: 'preparation'; readonly session: ProtocolURI; /** The chat the turn targets. */ readonly chat: ProtocolURI; @@ -124,6 +127,7 @@ export interface IHydrationContext { export interface IRestoredChat { readonly title?: string; readonly draft?: Message; + readonly canvases?: readonly CanvasState[]; } /** A client action after it has been reduced into host state. */ diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index da19f3ed57014..c7c26692fab36 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -5,9 +5,7 @@ import { vEnum, vObj, vOptionalProp, vString, type ValidatorType } from '../../../base/common/validation.js'; import type { AgentHostDebugLogsArtifactKind, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from './agentService.js'; -import type { InitializeResult } from './state/protocol/common/commands.js'; -import { AgentHostArtifactRemovalCapabilityMetaKey } from './meta/agentHostArtifactRemovalMeta.js'; - +export { getAgentHostExtensionInitializeResultMeta, supportsAgentHostCanvasChatInitialization, supportsAgentHostChatStateFile, supportsAgentHostDetachedWorktrees, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionInitializeResultMeta } from './meta/agentHostExtensionProtocolMeta.js'; export { supportsAgentHostArtifactRemoval } from './meta/agentHostArtifactRemovalMeta.js'; export const CollectAgentHostDebugLogsExtensionMethod = 'vscode/collectAgentHostDebugLogs'; @@ -19,38 +17,19 @@ export const ReconcileAgentHostDetachedWorktreesExtensionMethod = 'vscode/reconc export const ReadAgentHostDebugLogsChunkExtensionMethod = 'vscode/readAgentHostDebugLogsChunk'; export const SetAgentHostDetachedWorktreeArchivedExtensionMethod = 'vscode/setAgentHostDetachedWorktreeArchived'; export const RequestAgentHostWorkspaceTrustExtensionMethod = 'vscode/requestWorkspaceTrust'; +export const RequestAgentHostCanvasApprovalExtensionMethod = 'vscode/requestCanvasApproval'; +export const CancelAgentHostCanvasApprovalExtensionMethod = 'vscode/cancelCanvasApproval'; +export const InitializeCanvasChatExtensionMethod = 'vscode/initializeCanvasChat'; +export const CancelCanvasChatInitializationExtensionMethod = 'vscode/cancelCanvasChatInitialization'; export const RemoveSessionArtifactExtensionMethod = 'vscode/removeSessionArtifact'; -const AgentHostChatStateFileCapabilityMetaKey = 'vscode.getAgentHostSessionStateFile.chat'; -const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees'; - -export interface IAgentHostExtensionInitializeResultMeta extends Record { - readonly [AgentHostChatStateFileCapabilityMetaKey]?: true; - readonly [AgentHostDetachedWorktreeCapabilityMetaKey]?: true; - readonly [AgentHostArtifactRemovalCapabilityMetaKey]?: true; -} - -export interface IAgentHostExtensionInitializeResult extends InitializeResult { - readonly _meta?: IAgentHostExtensionInitializeResultMeta; -} - -export function getAgentHostExtensionInitializeResultMeta(artifactRemoval = true): IAgentHostExtensionInitializeResultMeta { - return { - [AgentHostChatStateFileCapabilityMetaKey]: true, - [AgentHostDetachedWorktreeCapabilityMetaKey]: true, - [AgentHostArtifactRemovalCapabilityMetaKey]: artifactRemoval ? true : undefined, - }; -} - -export function supportsAgentHostChatStateFile(result: IAgentHostExtensionInitializeResult | undefined): boolean { - const meta = result?._meta; - return meta?.[AgentHostChatStateFileCapabilityMetaKey] === true; -} +export const initializeCanvasChatParamsValidator = vObj({ + channel: vString(), + requestId: vString(), +}); -export function supportsAgentHostDetachedWorktrees(result: IAgentHostExtensionInitializeResult | undefined): boolean { - const meta = result?._meta; - return meta?.[AgentHostDetachedWorktreeCapabilityMetaKey] === true; -} +/** An exact chat and transport-scoped idempotency key for executable registry initialization. */ +export type InitializeCanvasChatParams = ValidatorType; export const collectAgentHostDebugLogsParamsValidator = vObj({ session: vOptionalProp(vString()), @@ -66,6 +45,8 @@ export const removeSessionArtifactParamsValidator = vObj({ }); export interface IAgentHostExtensionCommandMap { + [InitializeCanvasChatExtensionMethod]: { params: InitializeCanvasChatParams; result: void }; + [CancelCanvasChatInitializationExtensionMethod]: { params: InitializeCanvasChatParams; result: void }; [RemoveSessionArtifactExtensionMethod]: { params: ValidatorType; result: void; @@ -114,7 +95,18 @@ export interface IAgentHostWorkspaceTrustRequest { readonly trustedParent?: string; } +/** Out-of-turn, user-only approval. The nonce and exact chat are connection-bound. */ +export interface IAgentHostCanvasApprovalRequest { + readonly requestId: string; + readonly chat: string; + readonly message: string; +} + export interface IAgentHostExtensionServerCommandMap { + [RequestAgentHostCanvasApprovalExtensionMethod]: { + params: IAgentHostCanvasApprovalRequest; + result: { requestId: string; approved: boolean }; + }; [RequestAgentHostWorkspaceTrustExtensionMethod]: { params: IAgentHostWorkspaceTrustRequest; result: { trusted: boolean }; diff --git a/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts b/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts index ccfe311ae02c4..323424e2bc344 100644 --- a/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts +++ b/src/vs/platform/agentHost/common/agentHostSubscriptionService.ts @@ -7,7 +7,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { parseAnnotationsUri } from './annotationsUri.js'; import { parseChangesetUri } from './changesetUri.js'; -import { parseDefaultChatUri, parseSubagentSessionUri } from './state/sessionState.js'; +import { parseChatUri, parseSubagentSessionUri } from './state/sessionState.js'; export const IAgentHostSubscriptionService = createDecorator('agentHostSubscriptionService'); @@ -25,11 +25,11 @@ export interface IAgentHostSubscriptionService { hasSessionSubscribers(resource: URI): boolean; } -export function resolveAgentHostSession(resource: URI): URI { - const resourceString = resource.toString(); +export function resolveAgentHostSession(resource: URI, canvasChat?: string): URI { + const resourceString = canvasChat ?? resource.toString(); const changesetSession = parseChangesetUri(resourceString)?.sessionUri; const annotationsSession = parseAnnotationsUri(resourceString)?.sessionUri; - const chatSession = parseDefaultChatUri(resourceString); + const chatSession = parseChatUri(resourceString)?.session; let session = URI.parse(changesetSession ?? annotationsSession ?? chatSession ?? resourceString); let subagent; while ((subagent = parseSubagentSessionUri(session))) { diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e8b50ec7f934e..30064d9348410 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -16,6 +16,7 @@ import { AgentSandboxSettingId } from '../../sandbox/common/settings.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from './state/agentSubscription.js'; import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js'; import type { IAgentHostResourceUriMapper } from './agentHostUri.js'; +import type { IAgentCanvasApprovalClient, IAgentCanvasConnection } from './agentHostCanvases.js'; import type { IAgentHostClientTelemetryContext } from './agentHostTelemetry.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import type { AutomationCapabilities, InitializeResult } from './state/protocol/common/commands.js'; @@ -989,7 +990,7 @@ export interface IAgentService { * rather than {@link URI} objects so that authority-less scheme URIs * like `ahp-root://` survive the wire format without normalization. */ - dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext): void; + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContext?: IAgentHostClientTelemetryContext, canvasInitiator?: IAgentCanvasApprovalClient): void; /** * List the contents of a directory on the agent host's filesystem. @@ -1057,7 +1058,7 @@ export interface IAgentService { * Implementations wrap an {@link IAgentService} and layer subscription * management and optimistic write-ahead on top. */ -export interface IAgentConnection { +export interface IAgentConnection extends IAgentCanvasConnection { readonly clientId: string; readonly resourceUris: IAgentHostResourceUriMapper; diff --git a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts index c1f91dbe30f9b..c278a7547319f 100644 --- a/src/vs/platform/agentHost/common/ahpJsonlLogger.ts +++ b/src/vs/platform/agentHost/common/ahpJsonlLogger.ts @@ -10,6 +10,7 @@ import { joinPath } from '../../../base/common/resources.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'; +import { isCanvasRecord } from './agentHostCanvasValidation.js'; export type AhpLogDirection = 'c2s' | 's2c'; @@ -52,6 +53,15 @@ const MAX_LOG_LINE_LENGTH = 1024 * 1024; // length. Generous enough to keep messages useful for debugging. const MAX_LOGGED_STRING_LENGTH = 16 * 1024; +/** Uses the response shape so logging enabled after a request still cannot persist presentation credentials. */ +function redactCanvasSourceResponse(message: object): object { + if (isCanvasRecord(message) && isCanvasRecord(message.result) + && typeof message.result.availability === 'string' && typeof message.result.incarnation === 'string' + && typeof message.result.revision === 'number' && isCanvasRecord(message.result.source) && typeof message.result.source.url === 'string') { + return { ...message, result: { ...message.result, source: { redacted: true } } }; + } + return message; +} export class AhpJsonlLogger extends Disposable { @@ -94,7 +104,7 @@ export class AhpJsonlLogger extends Disposable { transport: this._options.transport, ...(typeof byteLength === 'number' ? { byteLength } : {}), }; - const entry = { ...message, _ahpLog: meta }; + const entry = { ...redactCanvasSourceResponse(message), _ahpLog: meta }; // Fast path: serialize once. The vast majority of messages are small, so // we only pay a single stringify and use its length to decide whether the // rare oversized-message path below is needed. diff --git a/src/vs/platform/agentHost/common/meta/agentCanvasSessionMeta.ts b/src/vs/platform/agentHost/common/meta/agentCanvasSessionMeta.ts new file mode 100644 index 0000000000000..c7465aec014fa --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentCanvasSessionMeta.ts @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const canvasSessionRetainedKey = 'vscode.canvasSessionRetained'; + +/** Whether an extension runtime has retained this session for explicitly approved executable work. */ +export function isCanvasSessionRetained(source: { readonly _meta?: Record } | undefined): boolean { + return source?._meta?.[canvasSessionRetainedKey] === true; +} + +export function withCanvasSessionRetained(meta: Record | undefined): Record { + return { ...meta, [canvasSessionRetainedKey]: true }; +} diff --git a/src/vs/platform/agentHost/common/meta/agentHostExtensionProtocolMeta.ts b/src/vs/platform/agentHost/common/meta/agentHostExtensionProtocolMeta.ts new file mode 100644 index 0000000000000..c3ab05765b1b3 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentHostExtensionProtocolMeta.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { InitializeResult } from '../state/protocol/common/commands.js'; +import { AgentHostArtifactRemovalCapabilityMetaKey } from './agentHostArtifactRemovalMeta.js'; + +const AgentHostChatStateFileCapabilityMetaKey = 'vscode.getAgentHostSessionStateFile.chat'; +const AgentHostDetachedWorktreeCapabilityMetaKey = 'vscode.detachedWorktrees'; +const AgentHostCanvasChatInitializationCapabilityMetaKey = 'vscode.initializeCanvasChat'; + +export interface IAgentHostExtensionInitializeResultMeta extends Record { + readonly [AgentHostChatStateFileCapabilityMetaKey]?: true; + readonly [AgentHostDetachedWorktreeCapabilityMetaKey]?: true; + readonly [AgentHostCanvasChatInitializationCapabilityMetaKey]?: true; + readonly [AgentHostArtifactRemovalCapabilityMetaKey]?: true; +} + +export interface IAgentHostExtensionInitializeResult extends InitializeResult { + readonly _meta?: IAgentHostExtensionInitializeResultMeta; +} + +export function getAgentHostExtensionInitializeResultMeta(canInitializeCanvasChat = false, canRemoveSessionArtifact = true): IAgentHostExtensionInitializeResultMeta { + return { + [AgentHostChatStateFileCapabilityMetaKey]: true, + [AgentHostDetachedWorktreeCapabilityMetaKey]: true, + [AgentHostArtifactRemovalCapabilityMetaKey]: canRemoveSessionArtifact ? true : undefined, + ...(canInitializeCanvasChat ? { [AgentHostCanvasChatInitializationCapabilityMetaKey]: true as const } : {}), + }; +} + +export function supportsAgentHostCanvasChatInitialization(result: IAgentHostExtensionInitializeResult | undefined): boolean { + return result?.canvases !== undefined && result._meta?.[AgentHostCanvasChatInitializationCapabilityMetaKey] === true; +} + +export function supportsAgentHostChatStateFile(result: IAgentHostExtensionInitializeResult | undefined): boolean { + return result?._meta?.[AgentHostChatStateFileCapabilityMetaKey] === true; +} + +export function supportsAgentHostDetachedWorktrees(result: IAgentHostExtensionInitializeResult | undefined): boolean { + return result?._meta?.[AgentHostDetachedWorktreeCapabilityMetaKey] === true; +} diff --git a/src/vs/platform/agentHost/common/meta/copilotCanvasMeta.ts b/src/vs/platform/agentHost/common/meta/copilotCanvasMeta.ts new file mode 100644 index 0000000000000..d716e9c5c15e3 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/copilotCanvasMeta.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Attachment, JsonValue } from '@github/copilot-sdk'; +import { isBoundedCanvasJson, isCanvasRecord } from '../agentHostCanvasValidation.js'; +import { MessageAttachmentKind, type MessageAttachment, type SimpleMessageAttachment } from '../state/protocol/state.js'; + +const extensionContextMetaKey = 'copilotExtensionContext'; + +export function isCopilotCanvasJson(value: unknown): value is JsonValue { + return isBoundedCanvasJson(value); +} + +export function extensionContextToProtocol(attachment: Extract, chat: string | undefined): SimpleMessageAttachment { + return { + type: MessageAttachmentKind.Simple, + label: attachment.title, + displayKind: 'extension-context', + modelRepresentation: JSON.stringify({ extensionId: attachment.extensionId, payload: attachment.payload ?? null }), + _meta: { [extensionContextMetaKey]: { chat, attachment } }, + }; +} + +export function readExtensionContext(attachment: MessageAttachment, chat: string): Extract | undefined { + const meta = attachment._meta?.[extensionContextMetaKey]; + if (!isCanvasRecord(meta) || meta.chat !== chat || !isBoundedCanvasJson(meta.attachment) || !isCanvasRecord(meta.attachment)) { + return undefined; + } + const value = meta.attachment; + const payload = value.payload; + if (value.type !== 'extension_context' || typeof value.extensionId !== 'string' || typeof value.title !== 'string' || typeof value.capturedAt !== 'string' + || value.canvasId !== undefined && typeof value.canvasId !== 'string' || value.instanceId !== undefined && typeof value.instanceId !== 'string') { + return undefined; + } + if (payload !== undefined && !isCopilotCanvasJson(payload)) { + return undefined; + } + return { + type: 'extension_context', extensionId: value.extensionId, title: value.title, capturedAt: value.capturedAt, + ...(typeof value.canvasId === 'string' ? { canvasId: value.canvasId } : {}), + ...(typeof value.instanceId === 'string' ? { instanceId: value.instanceId } : {}), + ...(payload !== undefined ? { payload } : {}), + }; +} diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 6ddb4214a5803..99dc424e363f6 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -183,6 +183,12 @@ export interface ISessionDatabase extends IDisposable { */ getTurnDelegations(): Promise>; + /** Persists host-observed message provenance, with the same lifetime and fork mapping as its turn. */ + setTurnMessageOrigin(turnId: string, origin: string): Promise; + + /** Restores message provenance keyed by both host turn ID and provider event ID. */ + getTurnMessageOrigins(): Promise>; + /** * Persists the JSON-serialized successful workspace transition for a turn. * Idempotent — last writer wins per turn. diff --git a/src/vs/platform/agentHost/common/state/agentSubscription.ts b/src/vs/platform/agentHost/common/state/agentSubscription.ts index d08a9a67dc2a8..85d0409730c8d 100644 --- a/src/vs/platform/agentHost/common/state/agentSubscription.ts +++ b/src/vs/platform/agentHost/common/state/agentSubscription.ts @@ -8,14 +8,16 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; -import { ActionEnvelope, ActionType, type AutomationAction, type AutomationRunAction, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction } from './sessionActions.js'; +import { ActionEnvelope, ActionType, type AutomationAction, type AutomationRunAction, ChangesetAction, ChatAction, AnnotationsAction, ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, ClientChangesetAction, IRootConfigChangedAction, SessionAction, StateAction, isChangesetAction, isChatAction, isAnnotationsAction, isSessionAction, isCanvasAction } from './sessionActions.js'; import { automationReducer, automationRunReducer, changesetReducer, chatReducer, annotationsReducer, rootReducer, sessionReducer } from './sessionReducers.js'; -import { terminalReducer } from './protocol/reducers.js'; +import { canvasReducer, terminalReducer } from './protocol/reducers.js'; +import type { CanvasState } from './protocol/channels-canvas/state.js'; import type { RootAction, SessionAction as IProtocolSessionAction, ChatAction as IProtocolChatAction, TerminalAction } from './protocol/action-origin.generated.js'; import type { AnnotationsState, AutomationRunState, AutomationState, ChangesetState, ChatState, RootState, SessionState, TerminalState } from './protocol/state.js'; -import type { IStateSnapshot } from './sessionProtocol.js'; -import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isAhpRootChannel, ROOT_STATE_URI, StateComponents } from './sessionState.js'; +import { AhpErrorCodes, ProtocolError, type IStateSnapshot } from './sessionProtocol.js'; +import { isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isAhpRootChannel, parseChatUri, ROOT_STATE_URI, StateComponents } from './sessionState.js'; import { normalizeLegacyChatStateErrors } from './legacyProtocolCompatibility.js'; // --- Public API -------------------------------------------------------------- @@ -613,6 +615,22 @@ export class TerminalStateSubscription extends BaseAgentSubscription { + + constructor(private readonly _resource: string, clientId: string, log: (msg: string) => void) { + super(clientId, log); + } + + protected override _applyReducer(state: CanvasState, action: StateAction): CanvasState { + return isCanvasAction(action) ? canvasReducer(state, action, this._log) : state; + } + + protected override _isRelevantEnvelope(envelope: ActionEnvelope): boolean { + return isCanvasAction(envelope.action) && envelope.channel === this._resource; + } +} + /** Subscription to the singleton host-owned automation catalogue. */ export class AutomationCatalogSubscription extends BaseAgentSubscription { @@ -758,7 +776,7 @@ export class ChangesetStateSubscription extends BaseAgentSubscription }; +type ManagedSubscriptionEntry = { + sub: ManagedSubscription; + kind: StateComponents; + refCount: number; + holders: Map; + isSubscribing: boolean; + canvasRecoveryIncarnation?: string; +}; // --- Subscription Manager ---------------------------------------------------- @@ -993,7 +1018,7 @@ export class AgentSubscriptionManager extends Disposable { getSubscription(kind: StateComponents, resource: URI, owner: string): IReference> { const existing = this._subscriptions.get(resource); if (existing) { - if (existing.sub.value instanceof Error) { + if (existing.sub.value instanceof Error && !existing.isSubscribing) { // Failed subscriptions should not poison the resource forever. Evict // the errored entry so this acquire performs a fresh subscribe. this._subscriptions.delete(resource); @@ -1007,36 +1032,82 @@ export class AgentSubscriptionManager extends Disposable { // Create new subscription based on caller-specified kind const key = resource.toString(); const sub = this._createSubscription(kind, key); - const entry: ManagedSubscriptionEntry = { sub, kind, refCount: 1, holders: new Map() }; + const entry: ManagedSubscriptionEntry = { sub, kind, refCount: 1, holders: new Map(), isSubscribing: false }; this._subscriptions.set(resource, entry); - // Kick off server subscription asynchronously. - // Capture the entry reference so we can validate it hasn't been - // replaced by a new subscription for the same key (race guard). - void (async () => { - const inflight = this._inflightCreates.get(resource); - if (inflight) { - try { - await inflight; - } catch { - // Swallow — fall through to subscribe so the error - // surfaces consistently via setError() on the - // subscription, matching the no-inflight path. - } - } + void this._subscribeEntry(resource, entry); + + return this._acquireReference(resource, entry, owner); + } + + private async _subscribeEntry(resource: URI, entry: ManagedSubscriptionEntry): Promise { + entry.isSubscribing = true; + const inflight = this._inflightCreates.get(resource); + if (inflight) { try { - const snapshot = await this._subscribe(resource); - if (this._subscriptions.get(resource) === entry) { - sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); + await inflight; + } catch { + // Fall through to subscribe so errors surface on the + // subscription consistently with the no-inflight path. + } + } + try { + if (this._subscriptions.get(resource) !== entry) { + return; + } + const snapshot = await this._subscribe(resource); + if (this._subscriptions.get(resource) === entry) { + entry.sub.handleSnapshot(snapshot.state as never, snapshot.fromSeq); + if (entry.sub instanceof SessionStateSubscription) { + this._recoverMissingCanvasSubscriptions(resource, entry.sub); } - } catch (err) { - if (this._subscriptions.get(resource) === entry) { - sub.setError(err instanceof Error ? err : new Error(String(err))); + } + } catch (err) { + if (this._subscriptions.get(resource) === entry) { + entry.isSubscribing = false; + entry.sub.setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + entry.isSubscribing = false; + if (this._subscriptions.get(resource) === entry && entry.sub instanceof CanvasStateSubscription && entry.sub.value instanceof Error) { + // The owner snapshot may have arrived before the initial + // NotFound response. Reconcile either delivery order. + for (const [owner, { sub }] of this._subscriptions) { + if (sub instanceof SessionStateSubscription) { + this._recoverMissingCanvasSubscriptions(owner, sub); + } } } - })(); + } + } - return this._acquireReference(resource, entry, owner); + /** + * An opaque canvas URI can be subscribed before its owner's durable + * membership is hydrated. Once that exact owner confirms the membership, + * retry only the failed initial state read, once per incarnation. Keep + * the held subscription and its original error until a real snapshot lands. + */ + private _recoverMissingCanvasSubscriptions(owner: URI, sub: SessionStateSubscription): void { + if (sub.value instanceof Error) { + return; + } + for (const canvas of sub.verifiedValue?.canvases ?? []) { + const chat = parseChatUri(canvas.identity.chat); + if (!chat || !isEqual(URI.parse(chat.session), owner)) { + continue; + } + const resource = URI.parse(canvas.resource); + const entry = this._subscriptions.get(resource); + if (!entry || !(entry.sub instanceof CanvasStateSubscription) || entry.isSubscribing || entry.sub.verifiedValue !== undefined) { + continue; + } + const error = entry.sub.value; + if (!(error instanceof ProtocolError) || error.code !== AhpErrorCodes.NotFound || entry.canvasRecoveryIncarnation === canvas.identity.incarnation) { + continue; + } + entry.canvasRecoveryIncarnation = canvas.identity.incarnation; + void this._subscribeEntry(resource, entry); + } } /** @@ -1090,6 +1161,13 @@ export class AgentSubscriptionManager extends Disposable { for (const { sub } of this._subscriptions.values()) { sub.receiveEnvelope(envelope); } + if (envelope.action.type === ActionType.SessionCanvasSet && !envelope.rejectionReason) { + const owner = URI.parse(envelope.channel); + const sub = this._subscriptions.get(owner)?.sub; + if (sub instanceof SessionStateSubscription) { + this._recoverMissingCanvasSubscriptions(owner, sub); + } + } } /** @@ -1264,6 +1342,8 @@ export class AgentSubscriptionManager extends Disposable { return new AutomationCatalogSubscription(this._clientId, this._log); case StateComponents.AutomationRun: return new AutomationRunSubscription(key, this._clientId, this._log); + case StateComponents.Canvas: + return new CanvasStateSubscription(key, this._clientId, this._log); case StateComponents.Root: throw new Error('_createSubscription: root subscription is managed separately'); default: diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 7dc5824dd8a37..5fb0e01cc35e2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -fd0471d4 +cd05c63c diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index e1e55844dc100..d1be1a43f45d9 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionWorkingDirectorySetAction, type SessionWorkingDirectoryRemovedAction, type SessionWorkingDirectoryReplacedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type SessionCanvasSetAction, type SessionCanvasRemovedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatToolCallAuthRequiredAction, type ChatToolCallAuthResolvedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatTurnResumeAction, type ChatActivityChangedAction, type ChatWorkingDirectorySetAction, type ChatWorkingDirectoryRemovedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction, type AutomationCreateRequestedAction, type AutomationUpdateRequestedAction, type AutomationSetAction, type AutomationRemovedAction, type AutomationRunLifecycleChangedAction, type AutomationRunSessionSetAction, type AutomationRunSessionRemovedAction, type AutomationRunPrimarySessionChangedAction, type AutomationRunCancelRequestedAction, type CanvasAvailabilityChangedAction, type CanvasTrustChangedAction, type CanvasIncarnationChangedAction, type CanvasTitleChangedAction, type CanvasIconChangedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -64,6 +64,8 @@ export type SessionAction = | SessionChangesetsChangedAction | SessionConfigChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction ; /** Union of session actions that clients may dispatch. */ @@ -100,6 +102,8 @@ export type ServerSessionAction = | SessionActivityChangedAction | SessionChangesetsChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction ; /** Union of all chat-scoped actions. */ @@ -316,6 +320,29 @@ export type ServerAutomationRunAction = | AutomationRunPrimarySessionChangedAction ; +/** Union of all canvas-scoped actions. */ +export type CanvasAction = + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction + | CanvasIconChangedAction + ; + +/** Union of canvas actions that clients may dispatch. */ +export type ClientCanvasAction = + never + ; + +/** Union of canvas actions that only the server may produce. */ +export type ServerCanvasAction = + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction + | CanvasIconChangedAction + ; + // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** @@ -355,6 +382,8 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionChangesetsChanged]: false, [ActionType.SessionConfigChanged]: true, [ActionType.SessionMetaChanged]: false, + [ActionType.SessionCanvasSet]: false, + [ActionType.SessionCanvasRemoved]: false, [ActionType.ChatTurnStarted]: true, [ActionType.ChatDelta]: false, [ActionType.ChatResponsePart]: false, @@ -419,4 +448,9 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.AutomationRunSessionRemoved]: false, [ActionType.AutomationRunPrimarySessionChanged]: false, [ActionType.AutomationRunCancelRequested]: true, + [ActionType.CanvasAvailabilityChanged]: false, + [ActionType.CanvasTrustChanged]: false, + [ActionType.CanvasIncarnationChanged]: false, + [ActionType.CanvasTitleChanged]: false, + [ActionType.CanvasIconChanged]: false, }; diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 445fa8cb79725..7220747b645f8 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -16,3 +16,4 @@ export * from './channels-annotations/actions.js'; export * from './channels-resource-watch/actions.js'; export * from './channels-automation/actions.js'; export * from './channels-automation-run/actions.js'; +export * from './channels-canvas/actions.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-canvas/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/actions.ts new file mode 100644 index 0000000000000..651466c4360bc --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/actions.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import { ActionType } from '../common/actions.js'; +import type { Icon } from '../common/state.js'; +import type { CanvasAvailabilityState, CanvasTrustState } from './state.js'; + +// ─── Canvas Actions ────────────────────────────────────────────────────────── + +/** + * Replaces the canvas's live resolution state. + * + * Dispatched by the host on every availability transition, including + * initial resolution after admission by `openCanvas` or a correlated native + * open, provider restart, and endpoint failure/recovery. A client-local page + * reload or transient presentation credential renewal alone does not require + * this action or a revision change. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasAvailabilityChangedAction { + type: ActionType.CanvasAvailabilityChanged; + /** New {@link CanvasState.availability}. */ + availability: CanvasAvailabilityState; + /** + * The {@link CanvasState.revision} this action results in. The reducer + * MUST reject (no-op) this action if `revision` is not strictly greater + * than the canvas's current `revision` — this is how stale/out-of-order + * deliveries are consistently rejected across every canvas action, not + * just this one. + */ + revision: number; +} + +/** + * Replaces the canvas's trust decision. + * + * Dispatched by the host whenever the execution-trust decision for this + * canvas's declared actions changes (e.g. a pending decision resolves, or an + * administrator revokes a previously trusted source). + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasTrustChangedAction { + type: ActionType.CanvasTrustChanged; + /** New {@link CanvasState.trust}. */ + trust: CanvasTrustState; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Records that the canvas's live endpoint was replaced by a fresh one for + * the same logical instance (e.g. the owning provider restarted). + * + * Renewing transient presentation credentials for the same live endpoint is + * not endpoint replacement and MUST NOT trigger this action. + * + * The host MUST dispatch {@link CanvasAvailabilityChangedAction} to + * transition through `notLoaded`/`loading` around this change. Receivers + * MUST reject in-flight `invokeCanvasAction` replies and stale server-pushed + * callbacks addressed to a superseded `incarnation` — because `incarnation` + * is opaque (see {@link CanvasIdentity.incarnation}), that rejection is + * driven by the accompanying `revision` bump here, not by comparing + * `incarnation` values for order. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasIncarnationChangedAction { + type: ActionType.CanvasIncarnationChanged; + /** New {@link CanvasIdentity.incarnation}. MUST differ from the previous value and MUST NOT be reused for this logical identity. */ + incarnation: string; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Replaces the canvas's display title. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasTitleChangedAction { + type: ActionType.CanvasTitleChanged; + /** New {@link CanvasState.title}. */ + title: string; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} + +/** + * Replaces or removes the canvas's display icon. + * + * This is presentation metadata only. It does not replace the live endpoint, + * change the canvas incarnation, or replay any canvas effect. + * + * @category Canvas Actions + * @version 1 + */ +export interface CanvasIconChangedAction { + type: ActionType.CanvasIconChanged; + /** New {@link CanvasState.icon}; `null` removes the current icon. */ + icon: Icon | null; + /** The {@link CanvasState.revision} this action results in; see {@link CanvasAvailabilityChangedAction.revision}. */ + revision: number; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-canvas/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/commands.ts new file mode 100644 index 0000000000000..95b9de8ca1b73 --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/commands.ts @@ -0,0 +1,368 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { URI, Icon } from '../common/state.js'; +import type { BaseParams, PaginatedParams, PaginatedResult } from '../common/commands.js'; +import type { CanvasAvailabilityStatus, CanvasEntry, CanvasIdentityKey, CanvasSourcePresentation, CanvasTypeDeclaration } from './state.js'; + +// ─── listCanvasTypes ───────────────────────────────────────────────────────── + +/** + * Discovers canvas TYPES currently available to open for one exact backing + * chat. + * + * This is a **pure read/browse** operation: it MUST NOT execute or start a + * provider, or open, materialize, or otherwise admit any canvas. See + * `openCanvas` for the admission rules, including publication of an + * already-open native instance. This catalogue is unrelated to + * {@link SessionState.canvases}, which reflects durable membership of + * already-opened canvas INSTANCES, not the set of canvas TYPES a + * host/extension could open; do not confuse the two. + * + * @category Commands + * @method listCanvasTypes + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ListCanvasTypesParams extends BaseParams, PaginatedParams { + /** The exact backing chat to discover available canvas types for. */ + channel: URI; +} + +/** + * Available canvas types for the requested chat. + * + * @category Commands + */ +export interface ListCanvasTypesResult extends PaginatedResult { + /** Discovered canvas type declarations. */ + types: CanvasTypeDeclaration[]; +} + +// ─── openCanvas ────────────────────────────────────────────────────────────── + +/** + * Explicitly opens (admits) a canvas, associating it with the owning chat + * given by `identity.chat` at the moment of the call — never with whichever + * chat later happens to have focus. + * + * Canvas membership requires explicit admission. A client admits a canvas + * by calling `openCanvas`, a read-write operation. `listCanvasTypes`, + * `subscribe`, and `resolveCanvasSource` MUST NOT admit a canvas or execute + * or start its provider. + * + * A host MAY also publish membership after observing an instance already + * opened by the owning native runtime. Before publication, the host MUST + * correlate the observation to the actual backing chat, canonical source, + * canvas type, and native instance, and enforce applicable + * execution-admission policy. Uncorrelated or conflicting observations MUST + * be rejected rather than assigned to the focused chat or a guessed source. + * Observation does not grant execution trust: it MUST NOT convert `pending` + * or `blocked` trust to `trusted`; trust and availability remain independent. + * + * Native publication follows the same singular identity-to-resource binding + * and authoritative state rules as client-originated admission. The host + * MUST NOT manufacture a client `openCanvas` request or invoke the provider's + * open handler again merely to publish an already-open instance. Repeated + * observations MUST NOT duplicate membership; this does not suppress the + * actual effects of a genuinely new native open. Hosts MUST preserve the + * native instance-ID namespace, including session-wide IDs across providers + * where the owning runtime requires them, rather than hide collisions with + * an invented provider namespace. Client `requestId` semantics are unchanged. + * + * Once admitted by either path, clients read and follow live state by + * `subscribe`-ing to `canvas.resource`, and resolve the current live endpoint + * via `resolveCanvasSource`; neither read opens, resumes, or restarts anything. + * + * **Logical identity is always singular.** The same {@link CanvasIdentityKey} + * (`chat`, `source`, `canvasType`, `instanceId`) always resolves to the same + * `canvas` resource URI and the same {@link SessionState.canvases} catalog + * entry, no matter how many times `openCanvas` is called or a native open is + * observed for it. The server MUST reuse that existing entry's `resource` + * rather than mint a second one. A client-supplied `canvas` URI is honored + * only on the call that first establishes the identity; on a later call for + * an already-recorded identity the server MUST ignore the supplied `canvas` + * value and return the existing resource instead. + * + * **Idempotency is scoped to `requestId`, not identity.** Retrying with the + * exact same `requestId` and byte-for-byte identical params from the same + * authenticated connection MUST return the original result without + * repeating any side effect, within a bounded live window (the server is + * not required to remember it forever). Reusing the same `requestId` with + * any different parameter value MUST be rejected with `Conflict` + * (`-32011`) — mint a new `requestId` for a new logical call. A genuinely + * NEW `requestId` for an already-open identity MAY be effectful (e.g. + * updating `title`/`icon`, or causing the provider to re-run its own + * open-time initialization with new `input`) — this mirrors the pinned + * SDK's own repeated-open behavior and does not create a second logical + * identity. There is no exactly-once-across-crash guarantee: a lost reply + * is indeterminate, and clients MUST NOT automatically replay `openCanvas` + * — reconnect and read `SessionState.canvases` / `resolveCanvasSource` + * instead to determine the actual outcome. + * + * @category Commands + * @method openCanvas + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface OpenCanvasParams extends BaseParams { + /** Session URI that will list the opened canvas in `SessionState.canvases`. */ + channel: URI; + /** Canvas URI (client-chosen, e.g. `ahp-canvas:/`); honored only when this call first establishes `identity` — see above. */ + canvas: URI; + /** Logical identity to open or re-admit. */ + identity: CanvasIdentityKey; + /** Initial (or updated, on a later effectful call) display title. */ + title: string; + /** Initial (or updated) display icon. */ + icon?: Icon; + /** + * Bounded JSON input for this open call (e.g. seed parameters the + * provider uses to initialize the canvas), opaque to the protocol. See + * {@link CanvasTypeDeclaration.openInputSchema} / + * `openInputSchemaRef` for the expected shape. The JSON-serialized value + * MUST NOT exceed `CANVAS_INPUT_MAX_LENGTH`. + */ + input?: unknown; + /** + * Durable client-generated idempotency key bounding retry deduplication + * for this call within a live window; see the idempotency rules above. + * MUST NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; +} + +/** + * Result identifying the existing or newly opened canvas. + * + * @category Commands + */ +export interface OpenCanvasResult { + /** The catalog entry for the opened (or already-open) canvas. */ + canvas: CanvasEntry; +} + +// ─── resolveCanvasSource ───────────────────────────────────────────────────── + +/** + * Reads a canvas's current live-resolution state and, when currently live, + * a transient endpoint presentation. + * + * This is read-only with respect to membership, provider execution/lifecycle, + * and durable canvas state. It MUST NOT admit a canvas or create, resume, + * reopen, or restart a provider, including on unavailable or unauthorized + * requests. Authorization failure MUST NOT expose `source`. + * + * For an already-live endpoint, authorized resolution MAY issue or refresh + * transient presentation credentials while constructing the response. Two + * resolutions of the same live incarnation MAY therefore return different + * URLs. Credential refresh alone MUST NOT rerun provider open, change the + * incarnation, or require a canvas state revision. If the live state changes + * concurrently during resolution, the response MUST report that state's + * current availability, revision, and incarnation; any returned `source` + * MUST correspond to that reported state. + * + * Clients MUST NOT replace a newer attachment with a superseded resolution + * response, even when credential renewal leaves `revision` and `incarnation` + * unchanged. These state guards do not order same-state credential refreshes. + * + * If the canvas does not currently have a live endpoint, `source` is absent + * and `availability` reflects why (e.g. `notLoaded`, `loading`, `failed`). + * Call `restartCanvasProvider` (an explicitly effectful operation) to + * attempt recovery instead; calling `resolveCanvasSource` again only + * retries reading the current state without restarting anything. + * + * A client-local page reload needs no provider restart or new effectful + * command. Before reload or reattachment, the client SHOULD resolve a fresh + * presentation unless the existing credential is known to remain valid and + * reusable. An absent expiry hint does not imply indefinite validity or + * reusability. Presentation URLs and credentials MUST NOT enter durable + * membership, editor restoration data, or routine logs. + * + * @category Commands + * @method resolveCanvasSource + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ResolveCanvasSourceParams extends BaseParams { + /** The canvas URI (an already-opened canvas's `resource`). */ + channel: URI; +} + +/** + * The canvas's current live-resolution state as of this read. + * + * @category Commands + */ +export interface ResolveCanvasSourceResult { + /** Current {@link CanvasEntry.availability}. */ + availability: CanvasAvailabilityStatus; + /** Current {@link CanvasIdentity.incarnation}. */ + incarnation: string; + /** Current {@link CanvasEntry.revision}. */ + revision: number; + /** Present only when a live endpoint currently exists (`availability` is `empty` or `ready`); absent otherwise. Transient — see {@link CanvasSourcePresentation}. */ + source?: CanvasSourcePresentation; +} + +// ─── invokeCanvasAction ────────────────────────────────────────────────────── + +/** + * Invokes one of a canvas's currently declared actions exactly once. + * + * The server MUST reject with `PermissionDenied` (`-32009`) if the canvas's + * current trust is not `trusted`, and with `NotFound` (`-32008`) if + * `actionId` does not match a currently declared action. `incarnation` is + * REQUIRED — omitting stale-generation protection on an effectful call is + * not allowed. If it does not match the canvas's current + * {@link CanvasIdentity.incarnation}, the server MUST reject with `Conflict` + * (`-32011`) rather than route the call to a superseded endpoint. + * + * The result is the provider's raw reply and is never persisted into + * `CanvasState` — large or provider-specific payloads stay off the durable + * state tree; a reply that would exceed `CANVAS_RESULT_MAX_LENGTH` MUST be + * represented out of band instead of being returned inline. Any resulting + * state changes (e.g. a subsequent availability transition) flow back + * separately through the normal `canvas/*` action stream on the canvas's + * own channel. + * + * A lost reply (e.g. a dropped connection after the provider already ran + * the handler) is **indeterminate**: clients MUST NOT automatically replay + * `invokeCanvasAction` on reconnect. Instead, reconnect and read the + * canvas's current state (e.g. via `subscribe` / `resolveCanvasSource`) and + * decide from observed `revision`/`incarnation` and any provider-visible + * side effect whether to surface the ambiguity to the user, rather than + * assuming success or failure. + * + * @category Commands + * @method invokeCanvasAction + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface InvokeCanvasActionParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** Matches a {@link CanvasActionDeclaration.id} from the canvas's current declared actions. */ + actionId: string; + /** + * Input conforming to the declared action's `inputSchema`/`inputSchemaRef`, + * if any. The JSON-serialized value MUST NOT exceed + * `CANVAS_INPUT_MAX_LENGTH`. + */ + input?: unknown; + /** + * Expected {@link CanvasIdentity.incarnation}. Required — see above. The + * server MUST reject the call with `Conflict` if the canvas's live + * endpoint has since been superseded, rather than deliver the call to it. + */ + incarnation: string; + /** + * Durable client-generated idempotency key bounding retry + * deduplication for this invocation within a live window. The server is + * not required to guarantee exactly-once execution across a crash. MUST + * NOT exceed `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; +} + +/** + * Result of invoking a declared canvas action. + * + * @category Commands + */ +export interface InvokeCanvasActionResult { + /** The provider's raw reply, opaque to the protocol. MUST NOT exceed `CANVAS_RESULT_MAX_LENGTH` once JSON-serialized. */ + result: unknown; +} + +// ─── restartCanvasProvider ─────────────────────────────────────────────────── + +/** + * Explicitly restarts the provider/chat-scoped runtime backing this canvas: + * retires the current live endpoint and establishes a fresh one for the + * same logical instance. + * + * This is the **only** operation that intentionally causes an + * {@link CanvasIncarnationChangedAction | incarnation bump}; `resolveCanvasSource` + * (read-only source resolution / client-local page reload) MUST NEVER + * trigger it. The host dispatches {@link CanvasAvailabilityChangedAction} + * (transitioning through `notLoaded`/`loading`) and then + * {@link CanvasIncarnationChangedAction} to reflect the outcome. Restart + * never replays a prior `invokeCanvasAction`, and MUST NOT steal focus or + * restore any prior in-flight effect. + * + * `incarnation` is REQUIRED: the server MUST reject with `Conflict` + * (`-32011`) if it does not match the canvas's current + * {@link CanvasIdentity.incarnation}, so a caller cannot restart a + * generation it never observed (e.g. after racing a concurrent restart). A + * lost reply is indeterminate; clients MUST NOT automatically replay this + * command — reconnect and compare the canvas's current `incarnation` + * instead. + * + * @category Commands + * @method restartCanvasProvider + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface RestartCanvasProviderParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; + /** Expected current {@link CanvasIdentity.incarnation}; required — see above. */ + incarnation: string; +} + +// ─── closeCanvas ───────────────────────────────────────────────────────────── + +/** + * Logically closes a canvas: removes its durable membership from + * `SessionState.canvases` and disposes matching views. + * + * This is distinct from a client merely hiding a local tab or view, which is + * presentation-only and MUST NOT dispatch this command. There is no + * advertised model tool for this operation — it is invoked only by + * UI/RPC callers. + * + * `revision` is REQUIRED: the server MUST reject with `Conflict` + * (`-32011`) if it does not match the canvas's current + * {@link CanvasEntry.revision}, so a caller cannot close membership state it + * never actually observed. If no matching entry exists (e.g. already + * closed), the server MUST treat this as a successful no-op rather than an + * error — the `revision` precondition only applies when an entry still + * exists. A lost reply is indeterminate; clients MUST NOT automatically + * replay this command — reconnect and check `SessionState.canvases` + * instead. + * + * @category Commands + * @method closeCanvas + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface CloseCanvasParams extends BaseParams { + /** The canvas URI. */ + channel: URI; + /** + * Durable client-generated idempotency key, following the same + * requestId-scoped idempotency rules as `openCanvas`. MUST NOT exceed + * `CANVAS_REQUEST_ID_MAX_LENGTH`. + */ + requestId: string; + /** Expected current {@link CanvasEntry.revision}; required when an entry still exists — see above. */ + revision: number; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-canvas/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/reducer.ts new file mode 100644 index 0000000000000..50bf407feafcc --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/reducer.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import { ActionType } from '../common/actions.js'; +import type { CanvasAction } from '../action-origin.generated.js'; +import type { CanvasState } from './state.js'; +import { softAssertNever } from '../common/reducer-helpers.js'; + +/** + * Pure reducer for canvas state. Handles all {@link CanvasAction} variants. + * + * Every variant carries the `revision` it results in. This reducer rejects + * (no-ops) any action whose `revision` is not strictly greater than the + * canvas's current `revision`, so a stale or out-of-order delivery can never + * overwrite newer state — including a hypothetical stale + * `canvas/incarnationChanged` reverting `identity.incarnation` to a + * superseded value. Applying an action always sets `state.revision` to the + * action's asserted `revision` (never a reducer-computed increment), keeping + * the contract consistent across all four action types. + */ +export function canvasReducer(state: CanvasState, action: CanvasAction, log?: (msg: string) => void): CanvasState { + switch (action.type) { + case ActionType.CanvasAvailabilityChanged: + if (action.revision <= state.revision) { + return state; + } + return { ...state, availability: action.availability, revision: action.revision }; + + case ActionType.CanvasTrustChanged: + if (action.revision <= state.revision) { + return state; + } + return { ...state, trust: action.trust, revision: action.revision }; + + case ActionType.CanvasIncarnationChanged: + if (action.revision <= state.revision) { + return state; + } + return { + ...state, + identity: { ...state.identity, incarnation: action.incarnation }, + revision: action.revision, + }; + + case ActionType.CanvasTitleChanged: + if (action.revision <= state.revision) { + return state; + } + return { ...state, title: action.title, revision: action.revision }; + + case ActionType.CanvasIconChanged: + if (action.revision <= state.revision) { + return state; + } + if (action.icon === null) { + const { icon: _, ...withoutIcon } = state; + return { ...withoutIcon, revision: action.revision }; + } + return { + ...state, + icon: action.icon, + revision: action.revision, + }; + + default: + softAssertNever(action, log); + return state; + } +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-canvas/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/state.ts new file mode 100644 index 0000000000000..5e7028e66338e --- /dev/null +++ b/src/vs/platform/agentHost/common/state/protocol/channels-canvas/state.ts @@ -0,0 +1,623 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// allow-any-unicode-comment-file +// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts + +import type { ErrorInfo, Icon, URI } from '../common/state.js'; + +// ─── Canvas Identity ───────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasSource} — what kind of package originates a + * canvas type. + * + * @category Canvas Identity + * @nonexhaustive + */ +export const enum CanvasSourceKind { + /** An explicitly installed host extension. */ + Extension = 'extension', + /** An explicitly installed package (not a host extension). */ + Package = 'package', +} + +/** + * A canvas type provided by an installed host extension. + * + * `extensionId` is the identity-bearing field for comparison purposes (see + * {@link CanvasIdentityKey}). `version` is display/informational metadata + * only — it MUST NOT be treated as identity-bearing (two `CanvasSource` + * values that differ only in `version` are the same source). + * + * @category Canvas Identity + */ +export interface CanvasExtensionSource { + kind: CanvasSourceKind.Extension; + /** + * Stable extension identifier (host-defined format, e.g. `publisher.name`). + * MUST NOT exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + extensionId: string; + /** Installed extension version, when known. Metadata only — not identity-bearing. */ + version?: string; +} + +/** + * A canvas type provided by an installed package that is not a host + * extension (e.g. a workspace-declared runtime package). + * + * `sourceId` — not `packageName` — is the identity-bearing field: the same + * declared package name MAY be installed in more than one scope (e.g. a + * workspace-local copy and a globally-installed copy, or two different + * registries), and each such installation is a distinct source with its own + * `sourceId`. `packageName` and `version` are display/informational metadata + * only and MUST NOT be treated as identity-bearing. + * + * @category Canvas Identity + */ +export interface CanvasPackageSource { + kind: CanvasSourceKind.Package; + /** + * Stable, host- or package-manager-assigned unique identifier for this + * specific installed package instance/scope (opaque format). This is the + * identity-bearing field — see {@link CanvasIdentityKey}. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + sourceId: string; + /** Declared package name, for display only — MUST NOT be used to compare source identity; see `sourceId`. */ + packageName: string; + /** Installed package version, when known. Metadata only — not identity-bearing. */ + version?: string; +} + +/** + * Identifies the explicitly installed extension or package that declares a + * canvas type. This is provenance for admission and display; it is not a + * grant of execution trust by itself — see {@link CanvasTrustStatus}. + * + * @category Canvas Identity + */ +export type CanvasSource = CanvasExtensionSource | CanvasPackageSource; + +/** + * The logical identity of a canvas, excluding the host-assigned + * {@link CanvasIdentity.incarnation | `incarnation`}. + * + * Two canvases are the same logical canvas iff `chat`, `canvasType`, + * `instanceId`, and `source`'s **identity-bearing** fields are all equal: + * `kind` plus `extensionId` (for {@link CanvasExtensionSource}) or `kind` + * plus `sourceId` (for {@link CanvasPackageSource}). `source.version` (and + * `CanvasPackageSource.packageName`) are metadata and MUST NOT factor into + * this comparison. Clients MUST NOT treat + * {@link CanvasIdentity.instanceId | `instanceId`} alone as a stable key — + * it is only unique within the scope of `(chat, source, canvasType)`. + * + * This logical tuple does not widen the owning runtime's native instance-ID + * namespace. A runtime may require session-wide native IDs across providers; + * hosts MUST preserve that constraint rather than hide native collisions + * with an invented provider namespace. + * + * @category Canvas Identity + */ +export interface CanvasIdentityKey { + /** + * The exact backing chat this canvas belongs to. A canvas is never + * re-associated with a different chat; opening a new one for another chat + * creates a distinct canvas. + */ + chat: URI; + /** The extension or package that declares this canvas's type. */ + source: CanvasSource; + /** + * Provider-declared canvas type (host/provider-defined format). MUST NOT + * exceed {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + canvasType: string; + /** + * Provider-chosen stable identifier for this canvas instance, scoped to + * `(chat, source, canvasType)`. Stable across reloads and host/window + * restarts for the same logical canvas. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + instanceId: string; +} + +/** + * Full identity of a canvas, including the host-assigned + * {@link CanvasIdentity.incarnation | `incarnation`}. + * + * @category Canvas Identity + */ +export interface CanvasIdentity extends CanvasIdentityKey { + /** + * Opaque, host-generated token identifying the current generation of this + * canvas's live endpoint. The host mints a fresh token whenever a provider + * restart retires the previous live endpoint and establishes a new one for + * the same logical instance (see {@link CanvasIncarnationChangedAction | + * `canvas/incarnationChanged`}); it is not changed by a plain page reload + * or transient presentation credential renewal for the same still-live + * endpoint. + * + * `incarnation` is **opaque**: clients and hosts MUST compare it only for + * equality, never parse it, sort it, or perform arithmetic on it (e.g. it + * is not guaranteed to be numeric or monotonically increasing). The host + * MUST NOT reuse a token for this logical identity once it has been + * superseded, including across a host/process restart — if the host + * cannot otherwise guarantee non-reuse, it MUST mint tokens (e.g. random + * or timestamp-derived) that make accidental reuse practically + * impossible, rather than a small resettable counter. + * + * Clients and hosts use `incarnation` to reject stale callbacks and + * in-flight effects addressed to a superseded endpoint. + */ + incarnation: string; +} + +// ─── Limits ────────────────────────────────────────────────────────────────── + +/** + * Maximum UTF-16 code units in a `requestId` (`openCanvas`, + * `invokeCanvasAction`, `restartCanvasProvider`, `closeCanvas`). Hosts MUST + * reject a longer value with `InvalidParams` (`-32602`) rather than + * truncate it. + * + * @category Canvas Limits + */ +export const CANVAS_REQUEST_ID_MAX_LENGTH = 256; + +/** + * Maximum UTF-16 code units in any single identity-bearing string field: + * {@link CanvasIdentityKey.canvasType}, {@link CanvasIdentityKey.instanceId}, + * {@link CanvasExtensionSource.extensionId}, or + * {@link CanvasPackageSource.sourceId}. Hosts MUST reject a longer value + * with `InvalidParams` (`-32602`) rather than truncate it. + * + * @category Canvas Limits + */ +export const CANVAS_IDENTITY_FIELD_MAX_LENGTH = 256; + +/** + * Maximum number of top-level `properties` entries an inline JSON Schema + * (`CanvasActionDeclaration.inputSchema` / + * `CanvasTypeDeclaration.openInputSchema`) may declare at any single nesting + * level. A schema that would exceed this MUST instead be represented via + * `inputSchemaRef` / `openInputSchemaRef`. + * + * @category Canvas Limits + */ +export const CANVAS_SCHEMA_MAX_PROPERTIES = 64; + +/** + * Maximum nesting depth of an inline JSON Schema + * (`CanvasActionDeclaration.inputSchema` / + * `CanvasTypeDeclaration.openInputSchema`), counting the root object as + * depth `1`. A schema that would exceed this MUST instead be represented + * via `inputSchemaRef` / `openInputSchemaRef`. + * + * @category Canvas Limits + */ +export const CANVAS_SCHEMA_MAX_DEPTH = 4; + +/** + * Maximum declared actions per canvas — + * {@link CanvasReadyAvailabilityState.actions} and + * {@link CanvasTypeDeclaration.declaredActions}. Hosts MUST NOT declare more + * than this; a provider with a larger action surface MUST group or page + * actions out of band rather than exceed this bound. + * + * @category Canvas Limits + */ +export const CANVAS_MAX_DECLARED_ACTIONS = 64; + +/** + * Maximum UTF-16 code units of the JSON-serialized `input` for `openCanvas` + * or `invokeCanvasAction`. Hosts MUST reject a larger `input` with + * `InvalidParams` (`-32602`). + * + * @category Canvas Limits + */ +export const CANVAS_INPUT_MAX_LENGTH = 65536; + +/** + * Maximum UTF-16 code units of the JSON-serialized `result` returned by + * `invokeCanvasAction`. A provider reply that would exceed this MUST be + * represented out of band (e.g. a resource the client resolves separately) + * rather than returned inline — large results are bounded/lazy references, + * never persisted session-summary metadata. + * + * @category Canvas Limits + */ +export const CANVAS_RESULT_MAX_LENGTH = 65536; + +/** + * Returns whether an inline JSON Schema object satisfies + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} and {@link CANVAS_SCHEMA_MAX_DEPTH}. + * Hosts MUST reject (or represent via a `*Ref` field instead of inlining) + * any schema for which this returns `false`. + * + * Only walks `properties`-shaped nesting (recursing into any property value + * that itself looks like a nested object schema, i.e. carries its own + * `properties`). A schema that manages to exceed the property or depth bound + * through some other JSON Schema construct (e.g. `$ref`, `items`, + * `oneOf`/`anyOf`) is out of scope for this helper and MUST still be + * rejected by a conformant host. + * + * @category Canvas Limits + */ +export function isCanvasSchemaWithinLimits( + schema: { readonly properties?: Record }, + depth = 1, +): boolean { + const props = schema.properties; + if (!props) { + return true; + } + if (Object.keys(props).length > CANVAS_SCHEMA_MAX_PROPERTIES) { + return false; + } + for (const value of Object.values(props)) { + if (!isRecord(value)) { + continue; + } + const nestedProperties = value.properties; + if (!isRecord(nestedProperties)) { + continue; + } + if (depth >= CANVAS_SCHEMA_MAX_DEPTH) { + return false; + } + if (!isCanvasSchemaWithinLimits({ properties: nestedProperties }, depth + 1)) { + return false; + } + } + return true; +} + +/** Type predicate narrowing an arbitrary schema-property value to a plain object, so `.properties` can be read without a type assertion. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +// ─── Trust ─────────────────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasTrustState} — whether the host currently + * permits this canvas's declared actions to execute. + * + * Trust is independent of {@link CanvasAvailabilityStatus | availability}: + * a canvas may be perfectly capable of rendering while blocked from + * executing actions, and vice versa. Trust decisions are host/runtime + * authority, not something this protocol grants. + * + * @category Canvas Trust + * @nonexhaustive + */ +export const enum CanvasTrustStatus { + /** Declared actions may be invoked. */ + Trusted = 'trusted', + /** A trust decision has not yet been made (e.g. first use of a new/changed source). */ + Pending = 'pending', + /** The host has denied execution; declared actions MUST NOT be invoked. */ + Blocked = 'blocked', +} + +/** @category Canvas Trust */ +export interface CanvasTrustedState { + status: CanvasTrustStatus.Trusted; +} + +/** @category Canvas Trust */ +export interface CanvasPendingTrustState { + status: CanvasTrustStatus.Pending; +} + +/** @category Canvas Trust */ +export interface CanvasBlockedTrustState { + status: CanvasTrustStatus.Blocked; + /** Optional human-readable reason surfaced to the user. */ + reason?: string; +} + +/** + * Current trust decision governing whether a canvas's declared actions may + * execute. + * + * @category Canvas Trust + */ +export type CanvasTrustState = + | CanvasTrustedState + | CanvasPendingTrustState + | CanvasBlockedTrustState; + +// ─── Declared Actions ──────────────────────────────────────────────────────── + +/** + * One action a canvas declares it can perform, invoked via + * `invokeCanvasAction`. + * + * Declarations are carried only on the full {@link CanvasState}, loaded when + * a client subscribes — never duplicated into the lightweight + * {@link CanvasEntry} catalog entry, keeping session summaries small. + * + * @category Canvas Actions + */ +export interface CanvasActionDeclaration { + /** Stable identifier, unique within this canvas, matching `invokeCanvasAction`'s `actionId`. */ + id: string; + /** Human-readable display name. */ + title?: string; + /** Description of what invoking the action does. */ + description?: string; + /** + * Inline JSON Schema for the expected `input`, when small enough to embed + * (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH}, + * checked by {@link isCanvasSchemaWithinLimits}). Optional because some + * declared actions take no input. Mutually exclusive with + * `inputSchemaRef` — a declaration MUST supply at most one of the two. + */ + inputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** + * Bounded out-of-band reference to a larger JSON Schema, used instead of + * `inputSchema` when the schema would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. AHP does not mandate a specific resolution mechanism for this + * URI (e.g. a host MAY make it `resourceRead`-able). + */ + inputSchemaRef?: URI; +} + +/** + * A canvas type an installed extension or package currently makes available + * to open for a chat, as returned by `listCanvasTypes`. + * + * `CanvasTypeDeclaration` is **discovery-only** metadata about a TYPE — it is + * unrelated to {@link CanvasEntry}, which represents durable membership of + * an already-opened INSTANCE in {@link SessionState.canvases}. Browsing the + * catalogue (via `listCanvasTypes`) MUST NOT execute or start a provider, + * or open, materialize, or admit a canvas. Membership requires `openCanvas` + * or host publication of a correlated, already-open native instance under + * that command's admission rules. + * + * @category Canvas State + */ +export interface CanvasTypeDeclaration { + /** The extension or package that declares this canvas type. */ + source: CanvasSource; + /** + * Provider-declared canvas type (host/provider-defined format), passed as + * {@link CanvasIdentityKey.canvasType} to `openCanvas`. MUST NOT exceed + * {@link CANVAS_IDENTITY_FIELD_MAX_LENGTH}. + */ + canvasType: string; + /** Human-readable display name for a canvas-type picker. */ + title: string; + /** Description of what this canvas type does. */ + description?: string; + /** Optional display icon. */ + icon?: Icon; + /** + * Inline JSON Schema describing the `openCanvas` `input` this type + * expects, when small enough to embed (see {@link CANVAS_SCHEMA_MAX_PROPERTIES} + * / {@link CANVAS_SCHEMA_MAX_DEPTH}). Mutually exclusive with + * `openInputSchemaRef`. + */ + openInputSchema?: { + type: 'object'; + properties?: Record; + required?: string[]; + }; + /** + * Bounded out-of-band reference to a larger open-input JSON Schema, used + * instead of `openInputSchema` when it would exceed + * {@link CANVAS_SCHEMA_MAX_PROPERTIES} / {@link CANVAS_SCHEMA_MAX_DEPTH} if + * inlined. + */ + openInputSchemaRef?: URI; + /** + * Advisory, statically-known preview of actions this canvas type + * typically declares once opened (bounded to + * {@link CANVAS_MAX_DECLARED_ACTIONS}). This is **not authoritative** — + * the actual invocable actions for an opened instance are always + * {@link CanvasReadyAvailabilityState.actions}, which MAY differ (e.g. + * depend on live provider configuration) and MUST be used instead of this + * preview once the canvas is open. + */ + declaredActions?: CanvasActionDeclaration[]; +} + +/** + * Transient, renderer-neutral presentation of a canvas's current live + * endpoint, returned by `resolveCanvasSource`. + * + * This is a plain URL, not any renderer- or process-model-specific handle + * (e.g. not an Electron `WebContentsView`, a browser tab id, or a webview + * panel reference) — how a client actually presents it (a VS Code Webview, + * the Integrated Browser, or otherwise) is entirely a client/host + * implementation detail outside this protocol. + * + * @category Canvas State + */ +export interface CanvasSourcePresentation { + /** + * Ephemeral URL to the canvas's current live endpoint. Transient: MUST + * NOT be persisted (including durable canvas/session state or editor + * restoration data), written to routine logs, or treated as a stable + * identity. A host MAY embed + * short-lived, single-use credentials in it; such credentials are never + * durable authority. Renewed credentials MAY produce a different URL for + * the same incarnation and revision. Reuse is safe only while the + * credential is known to remain valid and reusable. + */ + url: string; + /** + * Advisory expiry hint for `url` (and any embedded credential), when + * known. Omission does not imply indefinite validity or reusability, and + * an unexpired credential may still be single-use. + */ + expiresAt?: string; +} + +// ─── Availability ──────────────────────────────────────────────────────────── + +/** + * Discriminant for {@link CanvasAvailabilityState} — the canvas's current + * live resolution state, independent of its durable + * {@link CanvasEntry | membership} in a session's catalog. + * + * An empty catalog membership list is not itself a close, and a canvas may + * remain a recorded member while its live availability cycles through these + * states any number of times (e.g. across provider restarts). + * + * @category Canvas Availability + * @nonexhaustive + */ +export const enum CanvasAvailabilityStatus { + /** + * The connected client or host does not support this canvas type (e.g. + * the client omitted the `canvases` capability, or no local runtime can + * render this `canvasType`). Distinct from `blocked` trust, which is a + * policy decision rather than a capability gap. + */ + Unsupported = 'unsupported', + /** Recorded but not yet resolved to a live endpoint since it was opened or the host last restarted. */ + NotLoaded = 'notLoaded', + /** Currently resolving or (re)connecting to a live endpoint. */ + Loading = 'loading', + /** Live and reachable, but the provider has not yet produced content to render. */ + Empty = 'empty', + /** Live, reachable, and has declared its current actions. */ + Ready = 'ready', + /** The live endpoint failed to resolve, or resolution otherwise failed. */ + Failed = 'failed', +} + +/** @category Canvas Availability */ +export interface CanvasUnsupportedAvailabilityState { + status: CanvasAvailabilityStatus.Unsupported; +} + +/** @category Canvas Availability */ +export interface CanvasNotLoadedAvailabilityState { + status: CanvasAvailabilityStatus.NotLoaded; +} + +/** @category Canvas Availability */ +export interface CanvasLoadingAvailabilityState { + status: CanvasAvailabilityStatus.Loading; +} + +/** @category Canvas Availability */ +export interface CanvasEmptyAvailabilityState { + status: CanvasAvailabilityStatus.Empty; +} + +/** + * @category Canvas Availability + */ +export interface CanvasReadyAvailabilityState { + status: CanvasAvailabilityStatus.Ready; + /** Actions currently declared by the live provider (full replacement each time this state is produced). */ + actions: CanvasActionDeclaration[]; +} + +/** @category Canvas Availability */ +export interface CanvasFailedAvailabilityState { + status: CanvasAvailabilityStatus.Failed; + /** Stable machine-readable and human-readable failure information. */ + error: ErrorInfo; +} + +/** + * Current live resolution state of a canvas. + * + * @category Canvas Availability + */ +export type CanvasAvailabilityState = + | CanvasUnsupportedAvailabilityState + | CanvasNotLoadedAvailabilityState + | CanvasLoadingAvailabilityState + | CanvasEmptyAvailabilityState + | CanvasReadyAvailabilityState + | CanvasFailedAvailabilityState; + +// ─── Catalog Entry ─────────────────────────────────────────────────────────── + +/** + * Lightweight catalog entry for a canvas, carried in + * {@link SessionState.canvases | `SessionState.canvases`}. Presence + * represents durable **logical membership** — it is unaffected by the live + * {@link CanvasEntry.availability | `availability`} cycling through + * `notLoaded`/`loading`/`empty`/`ready`/`failed` any number of times. + * + * Membership is admitted by `openCanvas` or by host publication of a + * correlated, already-open native instance under that command's admission + * rules, never by discovery, subscription, or source resolution. + * + * The full state, including declared actions, lives in {@link CanvasState}, + * loaded when a client subscribes to {@link CanvasEntry.resource}. + * + * @category Canvas State + */ +export interface CanvasEntry { + /** Subscribable `ahp-canvas:` URI matching {@link CanvasState.resource}. */ + resource: URI; + /** Full identity, including current incarnation. */ + identity: CanvasIdentity; + /** Human-readable display title. */ + title: string; + /** Optional display icon. */ + icon?: Icon; + /** Current trust decision matching {@link CanvasState.trust}. */ + trust: CanvasTrustState; + /** Current availability status matching {@link CanvasState.availability}'s discriminant. */ + availability: CanvasAvailabilityStatus; + /** + * Monotonically increasing counter bumped on every change to this + * canvas's state (trust, availability, or incarnation). Clients MAY use it + * to detect and reject stale reads without a full deep comparison. + * Transient presentation credential renewal alone does not require a + * revision change. + */ + revision: number; + /** Opaque host-defined summary metadata. */ + _meta?: Record; +} + +/** + * Full state for a single canvas, loaded when a client subscribes to the + * canvas's URI. + * + * `CanvasState` **denormalizes** every {@link CanvasEntry} field directly + * onto itself, replacing `availability`'s lightweight status with the full + * {@link CanvasAvailabilityState} (including declared actions or failure + * detail). Producers MUST keep the two representations consistent: any + * change to the inlined fields SHOULD also be announced on the owning + * session via {@link SessionCanvasSetAction | `session/canvasSet`}. + * + * @category Canvas State + */ +export interface CanvasState { + /** URI of this canvas channel. */ + resource: URI; + /** Full identity, including current incarnation. */ + identity: CanvasIdentity; + /** Human-readable display title. */ + title: string; + /** Optional display icon. */ + icon?: Icon; + /** Current trust decision. */ + trust: CanvasTrustState; + /** Current live resolution state. */ + availability: CanvasAvailabilityState; + /** Matches {@link CanvasEntry.revision}. */ + revision: number; + /** Opaque host-defined metadata. */ + _meta?: Record; +} 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 cc01a0fcdc87d..66fffb74ded45 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 @@ -11,6 +11,7 @@ import type { ErrorInfo, URI } from '../common/state.js'; import type { ToolDefinition, SessionActiveClient, SessionInputRequest, Customization, CustomizationEnablement, McpServerState } from './state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { ChatSummary } from '../channels-chat/state.js'; +import type { CanvasEntry } from '../channels-canvas/state.js'; // ─── Session Actions ───────────────────────────────────────────────────────── @@ -186,6 +187,47 @@ export interface SessionChangesetsChangedAction { changesets: Changeset[] | undefined; } +/** + * A canvas was admitted (opened) or its catalog entry changed. + * + * Upsert semantics keyed by {@link CanvasEntry.resource | `resource`}: the + * server dispatches this with the full entry to record a newly opened + * canvas, or to republish it after a trust/availability/incarnation change + * so subscribers following only the session channel stay in sync with + * {@link CanvasState}. Never client-dispatchable: admission is through + * `openCanvas` or host publication of a correlated, already-open native + * instance under that command's admission rules. Both paths MUST use the + * same singular identity-to-resource binding; repeated native observations + * MUST NOT create a second entry. A stale/out-of-order delivery + * (`canvas.revision` not strictly greater than the currently-recorded entry's + * revision) MUST be rejected (no-op) rather than overwrite a newer entry with + * older data. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCanvasSetAction { + type: ActionType.SessionCanvasSet; + /** The canvas entry to add or update, matched by `resource`. */ + canvas: CanvasEntry; +} + +/** + * A canvas was logically closed. + * + * Remove semantics keyed by `resource`: an unknown URI is a no-op. This + * represents durable membership removal, not a client hiding a local + * tab/view — see `closeCanvas`. + * + * @category Session Actions + * @version 1 + */ +export interface SessionCanvasRemovedAction { + type: ActionType.SessionCanvasRemoved; + /** Entry in {@link SessionState.canvases} to remove, matching {@link CanvasEntry.resource}. */ + resource: URI; +} + /** * Server tools for this session have changed. * 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 b1a1b3c2c7aab..a665988934fd3 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 @@ -211,6 +211,36 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: : stateWithoutChangesets; } + case ActionType.SessionCanvasSet: { + const list = state.canvases ?? []; + const idx = list.findIndex(c => c.resource === action.canvas.resource); + if (idx < 0) { + return { ...state, canvases: [...list, action.canvas] }; + } + // Reject a stale/out-of-order membership update rather than let it + // overwrite a newer catalog entry with older data. + if (action.canvas.revision <= list[idx].revision) { + return state; + } + const updated = list.slice(); + updated[idx] = action.canvas; + return { ...state, canvases: updated }; + } + + case ActionType.SessionCanvasRemoved: { + const list = state.canvases; + if (!list) { + return state; + } + const idx = list.findIndex(c => c.resource === action.resource); + if (idx < 0) { + return state; + } + const updated = list.slice(); + updated.splice(idx, 1); + return { ...state, canvases: updated }; + } + case ActionType.SessionConfigChanged: if (!state.config) { return state; 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 445f33494ce5b..daffb834bab7c 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 @@ -7,6 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import type { Changeset } from '../channels-changeset/state.js'; +import type { CanvasEntry } from '../channels-canvas/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; import type { ChatSummary, ChatInputRequest, ToolCallConfirmationState, ToolCallRunningState, ToolCallAuthRequiredState } from '../channels-chat/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; @@ -206,6 +207,17 @@ export interface SessionState extends SessionMetadata { * {@link /guide/changesets | Changesets} for an overview of the model. */ changesets?: Changeset[]; + /** + * Catalog of canvases opened for chats in this session. Presence is + * durable logical membership, admitted via `openCanvas` or host publication + * of a correlated, already-open native instance under that command's + * admission rules. Membership is never implied by discovery, subscription, + * source resolution, a chat's existence, or a client's earlier focus. + * Each entry's {@link CanvasIdentity.chat | `identity.chat`} identifies the + * exact backing chat; a canvas never migrates to a different chat. See + * {@link CanvasEntry} for the full membership/availability/trust model. + */ + canvases?: CanvasEntry[]; /** * Outstanding input the session is blocked on, aggregated across every chat * so a client can discover and answer it from the session channel alone, diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 619fb6a4255c3..d16f169bb161e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -14,3 +14,4 @@ export * from './channels-terminal/commands.js'; export * from './channels-changeset/commands.js'; export * from './channels-resource-watch/commands.js'; export * from './channels-automation/commands.js'; +export * from './channels-canvas/commands.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 97ef944059b92..3a1a2a8dd1b1b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,7 +10,7 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionWorkingDirectorySetAction, SessionWorkingDirectoryRemovedAction, SessionWorkingDirectoryReplacedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction, SessionCanvasSetAction, SessionCanvasRemovedAction } from '../channels-session/actions.js'; import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatToolCallAuthRequiredAction, ChatToolCallAuthResolvedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatTurnResumeAction, ChatActivityChangedAction, ChatWorkingDirectorySetAction, ChatWorkingDirectoryRemovedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; @@ -23,6 +23,7 @@ import type { TerminalDataAction, TerminalInputAction, TerminalResizedAction, Te import type { ResourceWatchChangedAction } from '../channels-resource-watch/actions.js'; import type { AutomationCreateRequestedAction, AutomationRemovedAction, AutomationSetAction, AutomationUpdateRequestedAction } from '../channels-automation/actions.js'; import type { AutomationRunLifecycleChangedAction, AutomationRunSessionSetAction, AutomationRunSessionRemovedAction, AutomationRunPrimarySessionChangedAction, AutomationRunCancelRequestedAction } from '../channels-automation-run/actions.js'; +import type { CanvasAvailabilityChangedAction, CanvasTrustChangedAction, CanvasIncarnationChangedAction, CanvasTitleChangedAction, CanvasIconChangedAction } from '../channels-canvas/actions.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -129,6 +130,13 @@ export const enum ActionType { AutomationRunSessionRemoved = 'automationRun/sessionRemoved', AutomationRunPrimarySessionChanged = 'automationRun/primarySessionChanged', AutomationRunCancelRequested = 'automationRun/cancelRequested', + SessionCanvasSet = 'session/canvasSet', + SessionCanvasRemoved = 'session/canvasRemoved', + CanvasAvailabilityChanged = 'canvas/availabilityChanged', + CanvasTrustChanged = 'canvas/trustChanged', + CanvasIncarnationChanged = 'canvas/incarnationChanged', + CanvasTitleChanged = 'canvas/titleChanged', + CanvasIconChanged = 'canvas/iconChanged', } // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -197,6 +205,8 @@ export type StateAction = | SessionChangesetsChangedAction | SessionConfigChangedAction | SessionMetaChangedAction + | SessionCanvasSetAction + | SessionCanvasRemovedAction | ChatTurnStartedAction | ChatDeltaAction | ChatResponsePartAction @@ -260,4 +270,9 @@ export type StateAction = | AutomationRunSessionSetAction | AutomationRunSessionRemovedAction | AutomationRunPrimarySessionChangedAction - | AutomationRunCancelRequestedAction; + | AutomationRunCancelRequestedAction + | CanvasAvailabilityChangedAction + | CanvasTrustChangedAction + | CanvasIncarnationChangedAction + | CanvasTitleChangedAction + | CanvasIconChangedAction; 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 f20293b34fe5b..0653470e175e3 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -213,6 +213,25 @@ export interface ClientCapabilities { * App-bearing tool calls as ordinary MCP tool calls. */ mcpApps?: Record; + /** + * Client can render local canvases: `listCanvasTypes`, `openCanvas`, + * subscribe to the resulting `ahp-canvas:` channel, and drive + * `resolveCanvasSource` / `invokeCanvasAction` / `restartCanvasProvider` / + * `closeCanvas`. + * + * Hosts SHOULD NOT offer canvas admission to a client that omits this + * capability; such a client MUST be treated as if every canvas were + * {@link CanvasAvailabilityStatus.Unsupported}. Omission does not imply + * anything about server/runtime execution trust — see + * {@link CanvasTrustStatus}, which is a separate, host-owned decision. + * + * This declares only the CLIENT's rendering capability. Protocol version + * support alone (i.e. speaking >= 0.10.0) is not evidence that the SERVER + * actually has a working canvas runtime — see + * {@link InitializeResult.canvases}, the server-side counterpart, which a + * client MUST also check before treating canvases as usable. + */ + canvases?: Record; } /** @@ -287,8 +306,35 @@ export interface InitializeResult { * @see {@link /guide/automations | Automations Guide} */ automations?: AutomationCapabilities; + /** + * Host/runtime-owned local-canvas support. Presence means the SERVER + * currently has a working runtime able to serve `openCanvas` / + * `invokeCanvasAction` for at least one qualifying (explicitly installed + * and trust-eligible) extension/package source; absence means the host + * has no available canvas runtime, and clients MUST treat every canvas as + * {@link CanvasAvailabilityStatus.Unsupported} regardless of what + * {@link ClientCapabilities.canvases} declared. + * + * **Protocol version support alone is not a runtime capability**: a host + * speaking protocol `>= 0.10.0` without this field present MUST NOT be + * assumed to have a usable canvas runtime. This field — not the + * negotiated `protocolVersion` — is the authoritative signal, and is + * independent of any individual canvas's live availability + * ({@link CanvasAvailabilityState}) or trust decision + * ({@link CanvasTrustState}). + */ + canvases?: CanvasCapabilities; } +/** + * Local-canvas runtime features supported by this host authority. The empty + * object means "supported" — see {@link InitializeResult.canvases} for what + * presence/absence of this field itself means. + * + * @category Commands + */ +export interface CanvasCapabilities { } + /** * Automation features supported by this host authority. * diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index 93f06505ff666..de48029dbe2a1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -14,6 +14,7 @@ import type { CreateTerminalParams, DisposeTerminalParams } from '../channels-te import type { CreateResourceWatchParams, CreateResourceWatchResult } from '../channels-resource-watch/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../channels-changeset/commands.js'; import type { ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult, FetchAutomationRunsParams, FetchAutomationRunsResult } from '../channels-automation/commands.js'; +import type { ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, ResolveCanvasSourceResult, InvokeCanvasActionParams, InvokeCanvasActionResult, RestartCanvasProviderParams, CloseCanvasParams } from '../channels-canvas/commands.js'; import type { ActionEnvelope } from './actions.js'; import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams } from '../channels-root/notifications.js'; @@ -112,6 +113,12 @@ export interface CommandMap { 'listAutomationTriggerDefinitions': { params: ListAutomationTriggerDefinitionsParams; result: ListAutomationTriggerDefinitionsResult }; 'runAutomation': { params: RunAutomationParams; result: RunAutomationResult }; 'fetchAutomationRuns': { params: FetchAutomationRunsParams; result: FetchAutomationRunsResult }; + 'listCanvasTypes': { params: ListCanvasTypesParams; result: ListCanvasTypesResult }; + 'openCanvas': { params: OpenCanvasParams; result: OpenCanvasResult }; + 'resolveCanvasSource': { params: ResolveCanvasSourceParams; result: ResolveCanvasSourceResult }; + 'invokeCanvasAction': { params: InvokeCanvasActionParams; result: InvokeCanvasActionResult }; + 'restartCanvasProvider': { params: RestartCanvasProviderParams; result: null }; + 'closeCanvas': { params: CloseCanvasParams; result: null }; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts b/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts index 4ca1bc28cec50..aadecff959fa7 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/reducer-helpers.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import { IS_CLIENT_DISPATCHABLE, type RootAction, type ClientRootAction, type SessionAction, type ClientSessionAction, type TerminalAction, type ClientTerminalAction, type ChangesetAction, type ClientChangesetAction, type AnnotationsAction, type ClientAnnotationsAction, type AutomationAction, type ClientAutomationAction, type AutomationRunAction, type ClientAutomationRunAction } from '../action-origin.generated.js'; +import { IS_CLIENT_DISPATCHABLE, type RootAction, type ClientRootAction, type SessionAction, type ClientSessionAction, type TerminalAction, type ClientTerminalAction, type ChangesetAction, type ClientChangesetAction, type AnnotationsAction, type ClientAnnotationsAction, type AutomationAction, type ClientAutomationAction, type AutomationRunAction, type ClientAutomationRunAction, type CanvasAction, type ClientCanvasAction } from '../action-origin.generated.js'; /** * Soft assertion for exhaustiveness checking. Place in the `default` branch of @@ -29,6 +29,6 @@ export function softAssertNever(value: never, log?: (msg: string) => void): void * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction { +export function isClientDispatchable(action: RootAction | SessionAction | TerminalAction | ChangesetAction | AnnotationsAction | AutomationAction | AutomationRunAction | CanvasAction): action is ClientRootAction | ClientSessionAction | ClientTerminalAction | ClientChangesetAction | ClientAnnotationsAction | ClientAutomationAction | ClientAutomationRunAction | ClientCanvasAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index 14df0f4718f83..6b3c11858fcda 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -15,6 +15,7 @@ import type { AnnotationsState } from '../channels-annotations/state.js'; import type { ChatState } from '../channels-chat/state.js'; import type { AutomationState } from '../channels-automation/state.js'; import type { AutomationRunState } from '../channels-automation-run/state.js'; +import type { CanvasState } from '../channels-canvas/state.js'; // ─── Type Aliases ──────────────────────────────────────────────────────────── @@ -334,7 +335,7 @@ export interface Snapshot { /** The subscribed channel URI (e.g. `ahp-root://`, `ahp-session:/`, or `ahp-chat:/`) */ resource: URI; /** The current state of the resource */ - state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState; + state: RootState | SessionState | TerminalState | ChangesetState | ResourceWatchState | AnnotationsState | ChatState | AutomationState | AutomationRunState | CanvasState; /** The `serverSeq` at which this snapshot was taken. Subsequent actions will have `serverSeq > fromSeq`. */ fromSeq: number; } diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index 8004b19cf7899..29f70caddc179 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -15,4 +15,5 @@ export { annotationsReducer } from './channels-annotations/reducer.js'; export { resourceWatchReducer } from './channels-resource-watch/reducer.js'; export { automationReducer } from './channels-automation/reducer.js'; export { automationRunReducer } from './channels-automation-run/reducer.js'; +export { canvasReducer } from './channels-canvas/reducer.js'; export { softAssertNever, isClientDispatchable } from './common/reducer-helpers.js'; diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 1c2205dffb7a5..d8f3b58fc4b3c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -17,3 +17,4 @@ export * from './channels-otlp/state.js'; export * from './channels-resource-watch/state.js'; export * from './channels-automation/state.js'; export * from './channels-automation-run/state.js'; +export * from './channels-canvas/state.js'; 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 86a7dfeb64226..073e0a95c8b5f 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.9.0'; +export const PROTOCOL_VERSION = '0.10.0'; /** * Every protocol version a client built from this source tree is willing @@ -35,6 +35,7 @@ export const PROTOCOL_VERSION = '0.9.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ + '0.10.0', '0.9.0', '0.8.0', '0.7.0', @@ -177,6 +178,13 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.AutomationRunSessionRemoved]: '0.8.0', [ActionType.AutomationRunPrimarySessionChanged]: '0.8.0', [ActionType.AutomationRunCancelRequested]: '0.8.0', + [ActionType.SessionCanvasSet]: '0.10.0', + [ActionType.SessionCanvasRemoved]: '0.10.0', + [ActionType.CanvasAvailabilityChanged]: '0.10.0', + [ActionType.CanvasTrustChanged]: '0.10.0', + [ActionType.CanvasIncarnationChanged]: '0.10.0', + [ActionType.CanvasTitleChanged]: '0.10.0', + [ActionType.CanvasIconChanged]: '0.10.0', }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index 96297212274d8..1e96ae590422a 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -145,8 +145,9 @@ import { } from './protocol/actions.js'; import type { SessionSummary } from './protocol/state.js'; +export type { CanvasAction } from './protocol/action-origin.generated.js'; import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams as ProtocolSessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; -import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, ClientChangesetAction as IClientChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_, AutomationAction as IAutomationAction_, ClientAutomationAction as IClientAutomationAction_, AutomationRunAction as IAutomationRunAction_, ClientAutomationRunAction as IClientAutomationRunAction_ } from './protocol/action-origin.generated.js'; +import type { CanvasAction, RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, ClientChangesetAction as IClientChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_, AutomationAction as IAutomationAction_, ClientAutomationAction as IClientAutomationAction_, AutomationRunAction as IAutomationRunAction_, ClientAutomationRunAction as IClientAutomationRunAction_ } from './protocol/action-origin.generated.js'; export type SessionSummaryChanges = Omit, 'activity'> & { /** `null` explicitly clears activity; omission leaves it unchanged. */ @@ -244,6 +245,10 @@ export function isChatAction(action: StateAction): action is ChatAction { return action.type.startsWith('chat/'); } +export function isCanvasAction(action: StateAction): action is CanvasAction { + return action.type.startsWith('canvas/'); +} + export function isTerminalAction(action: StateAction): action is TerminalAction { return action.type.startsWith('terminal/'); } diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 33768d01d4b44..163c60aefea19 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -18,6 +18,7 @@ import type { IProductService } from '../../../product/common/productService.js' import { isAgentWorkspaceContinuationMessage } from '../meta/agentWorkspaceContinuationMeta.js'; import { readToolCallMeta } from '../meta/agentToolCallMeta.js'; import { readLegacyTurnError } from './legacyProtocolCompatibility.js'; +import type { CanvasState } from './protocol/channels-canvas/state.js'; import { MessageKind, ResponsePartKind, @@ -58,6 +59,8 @@ import { type Message, } from './protocol/state.js'; +export type { CanvasState, CanvasEntry, CanvasIdentityKey, CanvasSource } from './protocol/channels-canvas/state.js'; + // Re-export everything from the protocol state module export { ChangesetOperationScope, ChangesetOperationStatus, ChangesetStatus, CustomizationLoadStatus, @@ -1137,6 +1140,7 @@ export const enum StateComponents { Annotations, AutomationCatalog, AutomationRun, + Canvas, } export type ComponentToState = { @@ -1148,6 +1152,7 @@ export type ComponentToState = { [StateComponents.Annotations]: AnnotationsState; [StateComponents.AutomationCatalog]: AutomationState; [StateComponents.AutomationRun]: AutomationRunState; + [StateComponents.Canvas]: CanvasState; }; // ---- Default chat URI helpers ---------------------------------------------- diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index fb22bb9b79f03..fe0500b06ff05 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { DeferredPromise, disposableTimeout } from '../../../base/common/async.js'; +import type { CancellationToken } from '../../../base/common/cancellation.js'; +import type { InitializeCanvasChatParams } from '../common/agentHostExtensionProtocol.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IReference, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; @@ -64,6 +66,7 @@ import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTermi import type { Implementation, InitializeResult } from '../common/state/protocol/common/commands.js'; import { NonReconnectableTransportError } from '../common/state/sessionTransport.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import type { CloseCanvasParams, InvokeCanvasActionParams, InvokeCanvasActionResult, ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, ResolveCanvasSourceResult, RestartCanvasProviderParams } from '../common/state/protocol/channels-canvas/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js'; import type { CreateResourceWatchParams, CreateResourceWatchResult, ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWriteParams, ResourceWriteResult } from '../common/state/sessionProtocol.js'; import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientAutomationAction, ClientAutomationRunAction, ClientChangesetAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../common/state/sessionActions.js'; @@ -499,6 +502,34 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._requireClient().invokeChangesetOperation(params); } + listCanvasTypes(params: ListCanvasTypesParams): Promise { + return this._requireClient().listCanvasTypes(params); + } + + initializeCanvasChat(params: InitializeCanvasChatParams, token?: CancellationToken): Promise { + return this._requireClient().initializeCanvasChat(params, token); + } + + openCanvas(params: OpenCanvasParams): Promise { + return this._requireClient().openCanvas(params); + } + + resolveCanvasSource(params: ResolveCanvasSourceParams): Promise { + return this._requireClient().resolveCanvasSource(params); + } + + invokeCanvasAction(params: InvokeCanvasActionParams): Promise { + return this._requireClient().invokeCanvasAction(params); + } + + restartCanvasProvider(params: RestartCanvasProviderParams): Promise { + return this._requireClient().restartCanvasProvider(params); + } + + closeCanvas(params: CloseCanvasParams): Promise { + return this._requireClient().closeCanvas(params); + } + handleMcpRequest(channel: string, method: string, params: Record | undefined): Promise { return this._requireClient().handleMcpRequest(channel, method, params); } diff --git a/src/vs/platform/agentHost/node/agentHostCanvasApproval.ts b/src/vs/platform/agentHost/node/agentHostCanvasApproval.ts new file mode 100644 index 0000000000000..ac228b148c767 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCanvasApproval.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout, raceCancellationError } from '../../../base/common/async.js'; +import { CancellationTokenSource, type CancellationToken } from '../../../base/common/cancellation.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { parseChatUri } from '../common/state/sessionState.js'; +import type { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; +import type { AgentHostStateManager } from './agentHostStateManager.js'; +import type { IAgentCanvasApprovalClient, IAgentCanvasOperation } from '../common/agentHostCanvases.js'; + +/** Bounded connection-owned consent outside turns; never answered by model or autopilot input. */ +export class AgentHostCanvasApproval extends Disposable { + private readonly _pending = new Map(); + + constructor( + private readonly _state: AgentHostStateManager, + private readonly _connections: IAgentHostClientConnectionService, + private readonly _initialization: (chat: string) => IAgentCanvasOperation | undefined = () => undefined, + ) { + super(); + this._register(_state.onDidRemoveSession(session => { + for (const pending of this._pending.values()) { + if (parseChatUri(pending.chat)?.session === session) { + pending.cancellation.cancel(); + } + } + })); + this._register(toDisposable(() => { + for (const pending of this._pending.values()) { + pending.cancellation.cancel(); + } + })); + } + + async request(chat: string, message: string, token: CancellationToken, initiatingClientId?: string, initiator?: IAgentCanvasApprovalClient): Promise { + const session = parseChatUri(chat)?.session; + const initialization = this._initialization(chat); + const authority = initiator ?? initialization?.initiator; + const generation = this._state.getChatGeneration(chat); + const ownsChat = () => !initialization?.token.isCancellationRequested && (initialization + ? this._initialization(chat) === initialization + : !!session && this._state.getSessionState(session)?.chats.some(entry => entry.resource === chat) === true) + && generation === this._state.getChatGeneration(chat) && !authority?.token.isCancellationRequested; + if (this._store.isDisposed || token.isCancellationRequested || !session || !ownsChat() + || this._pending.size >= 32 || message.length > 16384) { + return false; + } + const subscribers = [...new Set([...this._connections.getSubscribedClients(chat), ...this._connections.getSubscribedClients(session)])].filter(client => this._connections.isClientConnected(client)); + if (initialization?.clientId !== undefined && initiatingClientId !== undefined && initialization.clientId !== initiatingClientId + || initialization?.initiator && authority !== initialization.initiator + || authority && initiatingClientId !== undefined && authority.clientId !== initiatingClientId) { + return false; + } + const clientId = initialization?.clientId ?? initiatingClientId ?? (subscribers.length === 1 ? subscribers[0] : undefined); + if (!clientId || !this._connections.isClientConnected(clientId) || !authority && this._connections.getConnectionCounts(clientId).clientTransportCount !== 1) { + return false; + } + const requestId = generateUuid(); + const store = new DisposableStore(); + const cancellation = store.add(new CancellationTokenSource(token)); + if (initialization) { + store.add(initialization.token.onCancellationRequested(() => cancellation.cancel())); + } + if (authority) { + store.add(authority.token.onCancellationRequested(() => cancellation.cancel())); + } + store.add(disposableTimeout(() => cancellation.cancel(), 120_000)); + this._pending.set(requestId, { chat, cancellation }); + try { + const request = { requestId, chat, message }; + return await raceCancellationError(authority ? authority.requestApproval(request, cancellation.token) : this._connections.requestCanvasApproval(clientId, request, cancellation.token), cancellation.token) + && !cancellation.token.isCancellationRequested && this._connections.isClientConnected(clientId) + && ownsChat(); + } catch { + return false; + } finally { + this._pending.delete(requestId); + store.dispose(); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCanvasOperationLedger.ts b/src/vs/platform/agentHost/node/agentHostCanvasOperationLedger.ts new file mode 100644 index 0000000000000..9243abee15295 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCanvasOperationLedger.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, disposableTimeout, raceCancellationError } from '../../../base/common/async.js'; +import { CancellationTokenSource, type CancellationToken } from '../../../base/common/cancellation.js'; +import { CancellationError } from '../../../base/common/errors.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import type { IAgentCanvasOperation } from '../common/agentHostCanvases.js'; +import { isBoundedCanvasJson } from '../common/agentHostCanvasValidation.js'; +import { CANVAS_REQUEST_ID_MAX_LENGTH } from '../common/state/protocol/channels-canvas/state.js'; +import { AhpErrorCodes, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; + +interface ICanvasOperationEntry { + readonly fingerprint: string; + readonly result: DeferredPromise; + started: boolean; + cancellation?: CancellationTokenSource; + completedAt?: number; +} + +export class CanvasOperationIndeterminateError extends ProtocolError { + constructor(cause?: unknown) { + super(AhpErrorCodes.Conflict, 'The canvas operation may have taken effect. Reconcile its state; do not automatically replay it.', { outcome: 'indeterminate' }); + this.cause = cause; + } +} + +/** One bounded retry window for one authenticated transport, never shared across reconnects. */ +export class AgentHostCanvasOperationLedger extends Disposable { + private readonly _entries = new Map>(); + private readonly _cancellation = this._register(new CancellationTokenSource()); + + constructor( + private readonly _capacity = 128, + private readonly _retentionMs = 5 * 60 * 1000, + private readonly _now: () => number = Date.now, + private readonly _operationTimeoutMs = 120_000, + ) { + super(); + } + + get token() { return this._cancellation.token; } + + execute(requestId: string, parameters: object, operation: (context: IAgentCanvasOperation) => Promise, token?: CancellationToken): Promise { + if (this._store.isDisposed) { + throw new CancellationError(); + } + if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > CANVAS_REQUEST_ID_MAX_LENGTH || !isBoundedCanvasJson(parameters, 128 * 1024)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Canvas request parameters must be bounded JSON.'); + } + for (const [key, entry] of this._entries) { + if (entry.completedAt !== undefined && entry.completedAt + this._retentionMs <= this._now()) { + this._entries.delete(key); + } + } + const fingerprint = JSON.stringify(parameters); + const previous = this._entries.get(requestId); + if (previous) { + if (previous.fingerprint !== fingerprint) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas request ID was already used with different parameters.'); + } + return previous.result.p; + } + if (this._entries.size >= this._capacity) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas retry window is full.'); + } + const entry: ICanvasOperationEntry = { fingerprint, result: new DeferredPromise(), started: false }; + this._entries.set(requestId, entry); + void (async () => { + const store = new DisposableStore(); + const cancellation = store.add(new CancellationTokenSource(this.token)); + entry.cancellation = cancellation; + if (token) { + store.add(token.onCancellationRequested(() => cancellation.cancel())); + if (token.isCancellationRequested) { + cancellation.cancel(); + } + } + store.add(disposableTimeout(() => cancellation.cancel(), this._operationTimeoutMs)); + try { + const result = await raceCancellationError(operation({ + token: cancellation.token, + willExecute: () => { + if (cancellation.token.isCancellationRequested) { + throw new CancellationError(); + } + entry.started = true; + }, + }), cancellation.token); + if (!entry.result.isSettled) { + entry.completedAt = this._now(); + await entry.result.complete(result); + } + } catch (error) { + if (!entry.result.isSettled) { + entry.completedAt = this._now(); + await entry.result.error(entry.started && !(error instanceof CanvasOperationIndeterminateError) ? new CanvasOperationIndeterminateError(error) : error); + } + } finally { + entry.cancellation = undefined; + store.dispose(); + } + })(); + return entry.result.p; + } + + cancel(requestId: string, parameters: object): void { + const entry = this._entries.get(requestId); + if (entry && entry.fingerprint !== JSON.stringify(parameters)) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas cancellation does not match its original request.'); + } + entry?.cancellation?.cancel(); + } + + override dispose(): void { + this._cancellation.cancel(); + for (const entry of this._entries.values()) { + if (!entry.result.isSettled) { + void entry.result.error(entry.started ? new CanvasOperationIndeterminateError() : new CancellationError()); + } + } + this._entries.clear(); + super.dispose(); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCanvasSchema.ts b/src/vs/platform/agentHost/node/agentHostCanvasSchema.ts new file mode 100644 index 0000000000000..8e75fc670f450 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCanvasSchema.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { equals } from '../../../base/common/objects.js'; +import { invalidCanvasParams, isBoundedCanvasJson, isCanvasRecord } from '../common/agentHostCanvasValidation.js'; + +const annotations = new Set(['$schema', '$id', '$comment', 'title', 'description', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'default']); +const keywords = new Set([ + ...annotations, 'type', 'enum', 'const', '$ref', '$defs', 'definitions', 'properties', 'required', 'additionalProperties', + 'items', 'prefixItems', 'additionalItems', 'minItems', 'maxItems', 'minLength', 'maxLength', + 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'allOf', 'anyOf', 'oneOf', +]); +const types = new Set(['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']); +type Check = (value: unknown) => boolean; + +/** Exact decimal arithmetic for JSON's finite, serialized numeric values. */ +function isMultipleOf(value: number, divisor: number): boolean { + const parts = (number: number) => { + const [mantissa, exponent = '0'] = number.toString().split('e'); + const [whole, fraction = ''] = mantissa.split('.'); + return { integer: BigInt(whole + fraction), exponent: Number(exponent) - fraction.length }; + }; + const left = parts(value); + const right = parts(divisor); + const exponent = Math.min(left.exponent, right.exponent); + return left.integer * 10n ** BigInt(left.exponent - exponent) % (right.integer * 10n ** BigInt(right.exponent - exponent)) === 0n; +} + +/** + * A bounded, non-transforming JSON Schema subset. Unsupported assertions and + * recursive/external references fail explicitly before provider execution. + * + * Zod's installed experimental JSON-Schema converter silently accepts malformed + * numeric assertions, ignores const beside enum, and counts UTF-16 code units. + * Those semantics cannot be used as an admission check. + */ +export function validateCanvasInput(schema: object, input: unknown): void { + if (!isBoundedCanvasJson(schema, 1024 * 1024) || !isBoundedCanvasJson(input === undefined ? {} : input)) { + throw invalidCanvasParams('Canvas schema or input exceeds the JSON bound.'); + } + const compiled = new Map(); + const ancestors = new Set(); + let steps = 0; + let nodes = 0; + const unsupported = (): never => { throw invalidCanvasParams('Canvas schema is malformed or uses an unsupported assertion or reference. No provider action was invoked.'); }; + const compile = (value: unknown, depth: number): Check => { + if (depth > 32 || ++nodes > 16384) { + return unsupported(); + } + if (typeof value === 'boolean') { + return () => value; + } + if (!isCanvasRecord(value) || ancestors.has(value)) { + return unsupported(); + } + const cached = compiled.get(value); + if (cached) { + return cached; + } + ancestors.add(value); + for (const key of Object.keys(value)) { + if (!keywords.has(key)) { + return unsupported(); + } + } + if (value.$schema !== undefined && value.$schema !== 'https://json-schema.org/draft/2020-12/schema' + && value.$schema !== 'http://json-schema.org/draft-07/schema#' && value.$schema !== 'https://json-schema.org/draft-07/schema#') { + return unsupported(); + } + if (value.$id !== undefined && (depth !== 1 || typeof value.$id !== 'string')) { + return unsupported(); + } + const checks: Check[] = []; + if (value.type !== undefined) { + const declared = Array.isArray(value.type) ? value.type : [value.type]; + if (!declared.length || declared.some(type => typeof type !== 'string' || !types.has(type)) || new Set(declared).size !== declared.length) { + return unsupported(); + } + checks.push(input => declared.some(type => type === 'integer' ? typeof input === 'number' && Number.isInteger(input) + : type === 'null' ? input === null : type === 'array' ? Array.isArray(input) + : type === 'object' ? isCanvasRecord(input) : typeof input === type)); + } + if (value.enum !== undefined) { + const choices = value.enum; + if (!Array.isArray(choices) || !choices.length || choices.length > 4096) { + return unsupported(); + } + checks.push(input => choices.some(choice => equals(choice, input))); + } + if (Object.hasOwn(value, 'const')) { + checks.push(input => equals(value.const, input)); + } + if (value.$ref !== undefined) { + if (typeof value.$ref !== 'string' || !/^#\/(?:\$defs|definitions)\/[^/~]+$/.test(value.$ref)) { + return unsupported(); + } + let target: unknown = schema; + for (const segment of value.$ref.slice(2).split('/')) { + target = isCanvasRecord(target) && Object.hasOwn(target, segment) ? target[segment] : undefined; + } + checks.push(compile(target, depth + 1)); + } + for (const key of ['$defs', 'definitions']) { + if (value[key] !== undefined) { + const definitions = value[key]; + if (!isCanvasRecord(definitions) || Object.keys(definitions).length > 4096) { + return unsupported(); + } + for (const definition of Object.values(definitions)) { + compile(definition, depth + 1); + } + } + } + for (const key of ['allOf', 'anyOf', 'oneOf']) { + if (value[key] !== undefined) { + const alternatives = value[key]; + if (!Array.isArray(alternatives) || !alternatives.length || alternatives.length > 64) { + return unsupported(); + } + const alternativesChecks = alternatives.map(alternative => compile(alternative, depth + 1)); + checks.push(input => key === 'allOf' ? alternativesChecks.every(check => check(input)) + : key === 'anyOf' ? alternativesChecks.some(check => check(input)) + : alternativesChecks.filter(check => check(input)).length === 1); + } + } + for (const key of ['minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'minItems', 'maxItems', 'minLength', 'maxLength']) { + if (value[key] !== undefined) { + const bound = value[key]; + if (typeof bound !== 'number' || !Number.isFinite(bound) + || key === 'multipleOf' && bound <= 0 + || ['minItems', 'maxItems', 'minLength', 'maxLength'].includes(key) && (!Number.isSafeInteger(bound) || bound < 0)) { + return unsupported(); + } + checks.push(input => { + switch (key) { + case 'minimum': return typeof input !== 'number' || input >= bound; + case 'maximum': return typeof input !== 'number' || input <= bound; + case 'exclusiveMinimum': return typeof input !== 'number' || input > bound; + case 'exclusiveMaximum': return typeof input !== 'number' || input < bound; + case 'multipleOf': return typeof input !== 'number' || isMultipleOf(input, bound); + case 'minItems': return !Array.isArray(input) || input.length >= bound; + case 'maxItems': return !Array.isArray(input) || input.length <= bound; + case 'minLength': return typeof input !== 'string' || [...input].length >= bound; + default: return typeof input !== 'string' || [...input].length <= bound; + } + }); + } + } + const properties = new Map(); + if (value.properties !== undefined) { + if (!isCanvasRecord(value.properties) || Object.keys(value.properties).length > 4096) { + return unsupported(); + } + for (const [key, child] of Object.entries(value.properties)) { + properties.set(key, compile(child, depth + 1)); + } + } + const required = value.required ?? []; + if (!Array.isArray(required) || !required.every((key): key is string => typeof key === 'string') || new Set(required).size !== required.length) { + return unsupported(); + } + const additionalProperties = compile(value.additionalProperties ?? true, depth + 1); + checks.push(input => !isCanvasRecord(input) || required.every(key => Object.hasOwn(input, key)) + && Object.entries(input).every(([key, input]) => (properties.get(key) ?? additionalProperties)(input))); + if (value.prefixItems !== undefined && Array.isArray(value.items) + || value.additionalItems !== undefined && !Array.isArray(value.items)) { + return unsupported(); + } + const prefix = value.prefixItems ?? (Array.isArray(value.items) ? value.items : []); + if (!Array.isArray(prefix) || prefix.length > 64) { + return unsupported(); + } + const prefixChecks = prefix.map(child => compile(child, depth + 1)); + const items = compile(Array.isArray(value.items) ? value.additionalItems ?? true : value.items ?? true, depth + 1); + checks.push(input => !Array.isArray(input) || input.every((entry, index) => (prefixChecks[index] ?? items)(entry))); + const check: Check = input => { + if (++steps > 65536) { + throw invalidCanvasParams('Canvas schema validation exceeded its bounded work budget. No provider action was invoked.'); + } + return checks.every(check => check(input)); + }; + compiled.set(value, check); + ancestors.delete(value); + return check; + }; + if (!compile(schema, 1)(input === undefined ? {} : input)) { + throw invalidCanvasParams('Canvas input does not match the current declared schema.'); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostCanvasesService.ts b/src/vs/platform/agentHost/node/agentHostCanvasesService.ts new file mode 100644 index 0000000000000..eee9e0638af9e --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostCanvasesService.ts @@ -0,0 +1,1275 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise, disposableTimeout, raceCancellationError, SequencerByKey } from '../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { CancellationError } from '../../../base/common/errors.js'; +import { Emitter, type Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore, type IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { equals } from '../../../base/common/objects.js'; +import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { localize } from '../../../nls.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentSession, type IAgent } from '../common/agent.js'; +import { InitializeCanvasChatExtensionMethod, type InitializeCanvasChatParams } from '../common/agentHostExtensionProtocol.js'; +import { SessionConfigKey } from '../common/sessionConfigKeys.js'; +import { isEqual } from '../../../base/common/resources.js'; +import type { IAgentCanvasApprovalClient, IAgentCanvasConnection, IAgentCanvasInstance, IAgentCanvasOperation, IAgentCanvasSnapshot, IAgentCanvases } from '../common/agentHostCanvases.js'; +import { AHP_CANVAS_SCHEME, canvasEntry, canvasIdentityKey, canvasSourceKey, invalidCanvasParams, isBoundedCanvasJson, isCanvasIcon, isCanvasIdentity, isCanvasRecord, isCanvasResource, validateCanvasActions, validateCanvasRequest, validateCanvasType } from '../common/agentHostCanvasValidation.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import type { CloseCanvasParams, InvokeCanvasActionParams, InvokeCanvasActionResult, ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, ResolveCanvasSourceResult, RestartCanvasProviderParams } from '../common/state/protocol/channels-canvas/commands.js'; +import { CANVAS_RESULT_MAX_LENGTH, CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasActionDeclaration, type CanvasAvailabilityState, type CanvasIdentityKey, type CanvasSourcePresentation, type CanvasState } from '../common/state/protocol/channels-canvas/state.js'; +import { ActionType } from '../common/state/sessionActions.js'; +import { AhpErrorCodes, ProtocolError, type IStateSnapshot } from '../common/state/sessionProtocol.js'; +import { isChatReadOnly, MessageKind, parseChatUri, SessionStatus, type MessageAttachment, type SessionState } from '../common/state/sessionState.js'; +import { AgentHostCanvasOperationLedger, CanvasOperationIndeterminateError } from './agentHostCanvasOperationLedger.js'; +import { AgentHostCanvasApproval } from './agentHostCanvasApproval.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; +import { isCanvasSessionRetained, withCanvasSessionRetained } from '../common/meta/agentCanvasSessionMeta.js'; +import { IAgentHostClientConnectionService } from './agentHostClientConnectionService.js'; +import { IAgentHostProviderService } from './agentHostProviderService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; +import { IAgentHostWorktreeIsolation } from './shared/worktreeIsolation.js'; + +export const IAgentHostCanvasesService = createDecorator('agentHostCanvasesService'); +const retainedSessionStorageKey = 'agentHost.canvasSessionRetained'; + +export interface IAgentHostCanvasConnection extends IAgentCanvasConnection, IDisposable { + readonly initiator: IAgentCanvasApprovalClient | undefined; + snapshot(resource: string): IStateSnapshot; + cancelCanvasChatInitialization(params: InitializeCanvasChatParams): void; + beginChatCreation(chat: string): IAgentHostCanvasInitializationLease; +} +export interface IAgentHostCanvasInitializationLease extends IAgentCanvasOperation, IDisposable { + assertValid(): void; + commit(): void; +} + +export interface IAgentHostCanvasTurnPreparation extends IDisposable { + run(prompt: string): Promise; + commit(): void; +} + +export interface IAgentHostCanvasesService { + readonly _serviceBrand: undefined; + readonly available: boolean; + readonly readiness: Promise | undefined; + readonly onDidReleaseHold: Event; + holdsSession(session: string): boolean; + connect(clientId: string, requestApproval?: IAgentCanvasApprovalClient['requestApproval']): IAgentHostCanvasConnection; + loadChat(chat: string): Promise; + persistChat(chat: string): Promise; + requestApproval(chat: string, message: string, token: CancellationToken, initiatingClientId?: string, initiator?: IAgentCanvasApprovalClient): Promise; + appendAttachments(chat: string, attachments: readonly MessageAttachment[]): void; + discardPendingAttachments(chat: string): void; + getChatInitialization(chat: string): IAgentCanvasOperation | undefined; + beginChatCreation(chat: string): IAgentHostCanvasInitializationLease; + isChatInitializing(chat: string): boolean; + cancelChatInitialization(chat: string): void; + cancelSessionInitialization(session: string): void; + assertChatInitialization(chat: string): void; + retainChat(chat: string, token: CancellationToken): Promise; + needsTurnInitialization(chat: string): boolean; + prepareForTurn(chat: string, turnId: string, prompt: string, clientId?: string): Promise; + beginTurnPreparation(chat: string, turnId: string, clientId?: string, initiator?: IAgentCanvasApprovalClient): IAgentHostCanvasTurnPreparation; + cancelTurnPreparation(chat: string, turnId: string): boolean; +} + +type CanvasOperationResult = { kind: 'open'; value: OpenCanvasResult } | { kind: 'action'; value: InvokeCanvasActionResult } | { kind: 'void' }; +interface ICanvasCursor { + readonly chat: string; + readonly signature: string; + readonly offset: number; +} + +/** Authoritative membership and live projection. Provider facets own execution and source authorization. */ +export class AgentHostCanvasesService extends Disposable implements IAgentHostCanvasesService { + declare readonly _serviceBrand: undefined; + private readonly _queue = new SequencerByKey(); + private readonly _writes = new SequencerByKey(); + private readonly _generations = new Map(); + private readonly _instanceGenerations = new Map(); + private readonly _pending = this._register(new DisposableMap()); + private readonly _pendingAttachments = this._register(new DisposableMap()); + private readonly _closed = new Map(); + private readonly _approval: AgentHostCanvasApproval; + private readonly _holds = new Map(); + private readonly _retained = this._register(new DisposableMap()); + private readonly _connections = this._register(new DisposableMap()); + private readonly _onDidReleaseHold = this._register(new Emitter()); + private readonly _initializing = this._register(new DisposableMap()); + private readonly _turnPreparations = this._register(new DisposableMap()); + readonly onDidReleaseHold = this._onDidReleaseHold.event; + + constructor( + @IAgentHostProviderService private readonly _providers: IAgentHostProviderService, + @IAgentHostStateManager private readonly _state: AgentHostStateManager, + @ISessionDataService private readonly _sessionData: ISessionDataService, + @ILogService private readonly _logService: ILogService, + @IAgentHostClientConnectionService clientConnections: IAgentHostClientConnectionService, + @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, + @IAgentHostAuthenticationService private readonly _authentication: IAgentHostAuthenticationService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpoint: IAgentHostGitHubEndpointService, + ) { + super(); + this._approval = this._register(new AgentHostCanvasApproval(this._state, clientConnections, chat => this.getChatInitialization(chat))); + this._register(this._providers.registerProviderInitializer(provider => { + if (!provider.canvases) { + return Disposable.None; + } + const store = new DisposableStore(); + store.add(provider.canvases.onDidChange(snapshot => this._observe(provider, snapshot))); + store.add(toDisposable(() => { + for (const [chat, pending] of this._pending) { + if (pending.provider === provider) { + this._pending.deleteAndDispose(chat); + } + } + if (!this._store.isDisposed) { + for (const resource of this._generations.keys()) { + const canvas = this._state.getCanvasState(resource); + if (canvas && this._chatOwner(canvas.identity.chat, false).provider === provider.id) { + this._instanceGenerations.set(resource, generateUuid()); + this._availability(resource, { status: CanvasAvailabilityStatus.NotLoaded }); + } + } + } + })); + return store; + })); + this._register(this._state.onDidRegisterChat(chat => { + const session = parseChatUri(chat)?.session; + if (session && this._retained.has(session)) { + this._state.markSessionUsed(session); + queueMicrotask(() => this._projectRetention(session)); + } + const pending = this._pending.get(chat); + if (pending) { + this._pending.deleteAndDispose(chat); + this._observe(pending.provider, pending.snapshot); + } + })); + this._register(this._state.onDidRemoveSession(session => { + this._retained.deleteAndDispose(session); + for (const [chat, initialization] of this._initializing) { + if (parseChatUri(chat)?.session === session) { + initialization.cancel(); + } + } + for (const [chat, preparation] of this._turnPreparations) { + if (parseChatUri(chat)?.session === session) { + preparation.cancellation.cancel(); + } + } + for (const [key, closed] of this._closed) { + if (parseChatUri(closed.chat)?.session === session) { + this._closed.delete(key); + } + } + for (const chat of this._pendingAttachments.keys()) { + if (parseChatUri(chat)?.session === session) { + this._pendingAttachments.deleteAndDispose(chat); + } + } + for (const chat of this._pending.keys()) { + if (parseChatUri(chat)?.session === session) { + this._pending.deleteAndDispose(chat); + } + } + for (const resource of this._generations.keys()) { + if (!this._state.getCanvasState(resource)) { + this._generations.delete(resource); + this._instanceGenerations.delete(resource); + } + } + })); + this._register(toDisposable(() => { + this._generations.clear(); + this._instanceGenerations.clear(); + this._closed.clear(); + })); + this._register(this._state.onDidMaterializeChat(chat => { + const pending = this._pendingAttachments.get(chat); + if (pending) { + this._pendingAttachments.deleteAndDispose(chat); + this.appendAttachments(chat, pending.attachments); + } + })); + } + + get available(): boolean { + return this._providers.getProviders().some(provider => provider.canvases?.available === true); + } + + get readiness(): Promise | undefined { + const pending = this._providers.getProviders().flatMap(provider => provider.canvases?.readiness ? [provider.canvases.readiness] : []); + return pending.length ? Promise.allSettled(pending).then(() => undefined) : undefined; + } + + holdsSession(session: string): boolean { + return this._holds.has(session); + } + + getChatInitialization(chat: string): IAgentCanvasOperation | undefined { + return this._initializing.get(chat); + } + + beginChatCreation(chat: string): IAgentHostCanvasInitializationLease { + return this._beginInitialization(chat, { token: CancellationToken.None, willExecute: () => { } }); + } + + isChatInitializing(chat: string): boolean { + return this._initializing.has(chat) || this._turnPreparations.get(chat)?.admitted === false; + } + + cancelChatInitialization(chat: string): void { + this._initializing.get(chat)?.cancel(); + this._turnPreparations.get(chat)?.cancellation.cancel(); + this._pending.deleteAndDispose(chat); + this.discardPendingAttachments(chat); + } + + cancelSessionInitialization(session: string): void { + for (const chat of new Set([...this._initializing.keys(), ...this._turnPreparations.keys()])) { + if (parseChatUri(chat)?.session === session) { + this.cancelChatInitialization(chat); + } + } + } + + assertChatInitialization(chat: string): void { + if (this._store.isDisposed) { + throw new CancellationError(); + } + this._initializing.get(chat)?.assertValid(); + } + + async retainChat(chat: string, token: CancellationToken): Promise { + const parsed = parseChatUri(chat); + if (!parsed || token.isCancellationRequested || this._store.isDisposed) { + throw new CancellationError(); + } + const initialization = this.getChatInitialization(chat); + const generation = this._state.getChatGeneration(chat); + if (!initialization && !this._hasChat(chat) || initialization?.token.isCancellationRequested) { + throw new CancellationError(); + } + const reference = this._sessionData.openDatabase(URI.parse(parsed.session)); + try { + await reference.object.setMetadata(retainedSessionStorageKey, 'true'); + if (token.isCancellationRequested || generation !== this._state.getChatGeneration(chat) || initialization && this.getChatInitialization(chat) !== initialization) { + throw new CancellationError(); + } + this._rememberRetention(parsed.session); + } finally { + reference.dispose(); + } + if (token.isCancellationRequested || initialization && this.getChatInitialization(chat) !== initialization) { + throw new CancellationError(); + } + } + + private _rememberRetention(session: string): void { + if (this._store.isDisposed) { + return; + } + this._retained.set(session, disposableTimeout(() => this._retained.deleteAndDispose(session), 120_000)); + this._state.markSessionUsed(session); + if (this._state.getSessionState(session)) { + this._projectRetention(session); + } + } + + private _projectRetention(session: string): void { + const state = this._state.getSessionState(session); + if (!state || !this._retained.has(session) || this._store.isDisposed) { + return; + } + this._retained.deleteAndDispose(session); + this._state.markSessionUsed(session); + if (!isCanvasSessionRetained(state)) { + this._state.dispatchServerAction(session, { type: ActionType.SessionMetaChanged, _meta: withCanvasSessionRetained(state._meta) }); + } + } + + needsTurnInitialization(chat: string): boolean { + if (!this._hasChat(chat) || this._state.isEphemeralSession(parseChatUri(chat)!.session)) { + return false; + } + const owner = this._chatOwner(chat, false); + if (isChatReadOnly(this._state.getChatState(chat)?.interactivity, (owner.status & SessionStatus.IsArchived) !== 0)) { + return false; + } + const provider = this._providers.getProvider(owner.provider)?.canvases; + return !!provider?.available && (provider.defersHostTurnStart === true || !provider.getSnapshot(chat) || this.isChatInitializing(chat) || !!this._state.getActiveTurnId(chat)); + } + + async prepareForTurn(chat: string, turnId: string, prompt: string, clientId?: string): Promise { + const preparation = this.beginTurnPreparation(chat, turnId, clientId); + try { + await preparation.run(prompt); + } finally { + preparation.dispose(); + } + } + + cancelTurnPreparation(chat: string, turnId: string): boolean { + const preparation = this._turnPreparations.get(chat); + if (preparation?.turnId !== turnId) { + return false; + } + preparation.cancellation.cancel(); + return true; + } + + beginTurnPreparation(chat: string, turnId: string, clientId?: string, initiator?: IAgentCanvasApprovalClient): IAgentHostCanvasTurnPreparation { + if (!this._hasChat(chat) || this._turnPreparations.has(chat) || this._turnPreparations.size >= 128 || this._store.isDisposed || initiator?.token.isCancellationRequested) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'Another host turn is preparing this chat.'); + } + const store = new DisposableStore(); + const cancellation = new CancellationTokenSource(initiator?.token); + store.add(toDisposable(() => cancellation.dispose(true))); + store.add(disposableTimeout(() => cancellation.cancel(), 120_000)); + store.add(this._hold(parseChatUri(chat)!.session)); + const entry = { turnId, admitted: false, clientId, cancellation, dispose: () => store.dispose() }; + const generation = this._state.getChatGeneration(chat); + this._turnPreparations.set(chat, entry); + let result: Promise | undefined; + return { + run: prompt => result ??= raceCancellationError(this._prepareTurn(chat, prompt, { + clientId, initiator, + token: cancellation.token, + willExecute: () => { + if (cancellation.token.isCancellationRequested) { + throw new CancellationError(); + } + }, + }), cancellation.token), + commit: () => { + if (cancellation.token.isCancellationRequested || generation !== this._state.getChatGeneration(chat) || this._state.getActiveTurnId(chat)) { + throw new CancellationError(); + } + this._chatOwner(chat, true); + entry.admitted = true; + }, + dispose: () => { + if (this._turnPreparations.get(chat) === entry) { + this._turnPreparations.deleteAndDispose(chat); + } + }, + }; + } + + private async _prepareTurn(chat: string, prompt: string, operation: IAgentCanvasOperation): Promise { + const store = new DisposableStore(); + try { + await this._runOperation(chat, operation, () => this._initializeChat(chat, operation, prompt)); + if (this._state.getActiveTurnId(chat)) { + const idle = new DeferredPromise(); + store.add(this._state.onDidEmitEnvelope(event => { + if (event.channel === chat && !this._state.getActiveTurnId(chat)) { + void idle.complete(); + } + })); + store.add(disposableTimeout(() => { void idle.error(new ProtocolError(AhpErrorCodes.Conflict, 'The native initialization turn did not finish before host turn admission.')); }, 120_000)); + await raceCancellationError(idle.p, operation.token); + } + operation.willExecute(); + } finally { + store.dispose(); + } + } + + private _hold(session: string): IDisposable { + this._holds.set(session, (this._holds.get(session) ?? 0) + 1); + return toDisposable(() => { + const remaining = (this._holds.get(session) ?? 1) - 1; + if (remaining) { + this._holds.set(session, remaining); + } else { + this._holds.delete(session); + this._onDidReleaseHold.fire(session); + } + }); + } + + private _beginInitialization(chat: string, operation: IAgentCanvasOperation): IAgentHostCanvasInitializationLease { + const parsed = parseChatUri(chat); + if (!parsed || this._initializing.has(chat) || this._initializing.size >= 128 || operation.token.isCancellationRequested || this._store.isDisposed) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The exact chat cannot acquire an initialization lease.'); + } + const store = new DisposableStore(); + const cancellation = new CancellationTokenSource(operation.token); + let committed = false; + let disposed = false; + store.add(toDisposable(() => cancellation.dispose(!committed))); + store.add(this._hold(parsed.session)); + store.add(disposableTimeout(() => cancellation.cancel(), 120_000)); + if (!this._state.getChatState(chat)) { + store.add(this._state.beginPendingChat(chat)); + } + const generation = this._state.getChatGeneration(chat); + const lease: IAgentHostCanvasInitializationLease & { cancel(): void } = { + ...operation, + token: cancellation.token, + cancel: () => cancellation.cancel(), + assertValid: () => { + if (cancellation.token.isCancellationRequested || generation !== this._state.getChatGeneration(chat)) { + throw new CancellationError(); + } + }, + willExecute: () => { + lease.assertValid(); + operation.willExecute(); + }, + commit: () => { + if (cancellation.token.isCancellationRequested || generation !== this._state.getChatGeneration(chat) || !this._hasChat(chat)) { + throw new CancellationError(); + } + committed = true; + }, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + if (this._initializing.get(chat) === lease) { + this._initializing.deleteAndLeak(chat); + } + if (!committed) { + this._pending.deleteAndDispose(chat); + this.discardPendingAttachments(chat); + } + store.dispose(); + }, + }; + this._initializing.set(chat, lease); + return lease; + } + + private async _runOperation(chat: string, operation: IAgentCanvasOperation, run: () => Promise): Promise { + const session = parseChatUri(chat)?.session; + if (!session) { + throw invalidCanvasParams('The canvas operation has no owning chat.'); + } + const hold = this._hold(session); + return this._queue.queue(chat, async () => { + try { + if (operation.token.isCancellationRequested || this._store.isDisposed) { + throw new CancellationError(); + } + return await run(); + } finally { + hold.dispose(); + } + }); + } + + requestApproval(chat: string, message: string, token: CancellationToken, initiatingClientId?: string, initiator?: IAgentCanvasApprovalClient): Promise { + return this._approval.request(chat, message, token, initiatingClientId, initiator); + } + + appendAttachments(chat: string, attachments: readonly MessageAttachment[]): void { + if (!parseChatUri(chat) || attachments.length === 0 || this._store.isDisposed) { + return; + } + const state = this._state.getChatState(chat); + const combined = [...(state?.draft?.attachments ?? this._pendingAttachments.get(chat)?.attachments ?? []), ...attachments]; + const length = JSON.stringify(combined).length; + if (combined.length > 64 || length > 16 * 1024 * 1024) { + return; + } + if (!state) { + if (!this._pendingAttachments.has(chat) && this._pendingAttachments.size >= 64 + || length + [...this._pendingAttachments].reduce((sum, [key, entry]) => sum + (key === chat ? 0 : entry.length), 0) > 16 * 1024 * 1024) { + return; + } + const store = new DisposableStore(); + this._pendingAttachments.set(chat, { attachments: structuredClone(combined), length, dispose: () => store.dispose() }); + store.add(disposableTimeout(() => this._pendingAttachments.deleteAndDispose(chat), 120_000)); + return; + } + const initialization = this.getChatInitialization(chat); + if (initialization?.token.isCancellationRequested) { + return; + } + if (!initialization) { + this._provider(chat, true); + } + this._state.dispatchServerAction(chat, { + type: ActionType.ChatDraftChanged, + draft: { + ...(state.draft ?? { text: '', origin: { kind: MessageKind.User } }), + attachments: combined, + }, + }); + } + + discardPendingAttachments(chat: string): void { + this._pendingAttachments.deleteAndDispose(chat); + } + + connect(clientId: string, requestApproval?: IAgentCanvasApprovalClient['requestApproval']): IAgentHostCanvasConnection { + if (this._store.isDisposed) { + throw new CancellationError(); + } + const key = Symbol('Canvas connection'); + const store = new DisposableStore(); + this._connections.set(key, store); + const ledger = store.add(new AgentHostCanvasOperationLedger()); + const initiator: IAgentCanvasApprovalClient | undefined = requestApproval ? { clientId, token: ledger.token, requestApproval } : undefined; + const cursors = new Map(); + store.add(toDisposable(() => cursors.clear())); + return { + initiator, + dispose: () => { + this._connections.deleteAndDispose(key); + }, + beginChatCreation: chat => { + const lease = this._beginInitialization(chat, { clientId, initiator, token: ledger.token, willExecute: () => { } }); + return lease; + }, + cancelCanvasChatInitialization: params => ledger.cancel(params.requestId, { method: InitializeCanvasChatExtensionMethod, params }), + initializeCanvasChat: async (params, token) => { + if (!parseChatUri(params.channel)) { + throw invalidCanvasParams('Canvas initialization requires an exact chat URI.'); + } + await ledger.execute(params.requestId, { method: InitializeCanvasChatExtensionMethod, params }, operation => this._runOperation(params.channel, operation, async () => { + await this._initializeChat(params.channel, { ...operation, clientId: clientId || undefined, initiator }); + return { kind: 'void' }; + }), token); + }, + snapshot: resource => { + const state = this._require(resource); + this._chatOwner(state.identity.chat, false); + return { resource, state, fromSeq: this._state.serverSeq }; + }, + listCanvasTypes: async params => { + validateCanvasRequest('listCanvasTypes', params); + if (ledger.token.isCancellationRequested) { + throw new CancellationError(); + } + return this._list(params, cursors); + }, + resolveCanvasSource: async params => { + validateCanvasRequest('resolveCanvasSource', params); + return this._resolve(params, clientId, ledger.token); + }, + openCanvas: async params => { + validateCanvasRequest('openCanvas', params); + const result = await ledger.execute(params.requestId, { method: 'openCanvas', params }, operation => this._runOperation(params.identity.chat, operation, async () => ({ kind: 'open', value: await this._open(params, { ...operation, clientId, initiator }) }))); + if (result.kind !== 'open') { + throw new Error('Unexpected canvas open result.'); + } + return result.value; + }, + invokeCanvasAction: async params => { + validateCanvasRequest('invokeCanvasAction', params); + const result = await ledger.execute(params.requestId, { method: 'invokeCanvasAction', params }, operation => { + const chat = this._require(params.channel).identity.chat; + return this._runOperation(chat, operation, async () => ({ kind: 'action', value: await this._invoke(params, { ...operation, clientId, initiator }) })); + }); + if (result.kind !== 'action') { + throw new Error('Unexpected canvas action result.'); + } + return result.value; + }, + closeCanvas: async params => { + validateCanvasRequest('closeCanvas', params); + await ledger.execute(params.requestId, { method: 'closeCanvas', params }, async operation => { + const state = this._state.getCanvasState(params.channel); + if (state) { + await this._runOperation(state.identity.chat, operation, () => this._close(params, { ...operation, clientId, initiator })); + } + return { kind: 'void' }; + }); + }, + restartCanvasProvider: async params => { + validateCanvasRequest('restartCanvasProvider', params); + await ledger.execute(params.requestId, { method: 'restartCanvasProvider', params }, operation => { + const chat = this._require(params.channel).identity.chat; + return this._runOperation(chat, operation, async () => { await this._restart(params, { ...operation, clientId, initiator }); return { kind: 'void' }; }); + }); + }, + }; + } + + private async _initializeChat(chat: string, operation: IAgentCanvasOperation, prompt?: string): Promise { + const provider = this._provider(chat, true); + if (this._state.isEphemeralSession(parseChatUri(chat)!.session)) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'Ephemeral chats do not initialize extension runtimes.'); + } + if (!provider.available || this._state.getActiveTurnId(chat) && !provider.getSnapshot(chat)) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'Canvas initialization requires an idle, eligible chat.'); + } + const lease = this._beginInitialization(chat, operation); + try { + const owner = this._chatOwner(chat, true); + const session = URI.parse(parseChatUri(chat)!.session); + const sessionId = AgentSession.id(session); + let workingDirectories = owner.workingDirectories?.map(directory => URI.parse(directory)); + if (owner.config?.values[SessionConfigKey.Isolation] === 'worktree' && !provider.getSnapshot(chat)) { + lease.willExecute(); + const resource = prompt === undefined ? undefined : this._gitHubEndpoint.getCopilotResource(); + const githubToken = resource ? this._authentication.getAuthToken({ resource: resource.resource, scopes: resource.scopes_supported }) : undefined; + const resolved = await this._worktree.resolveForInitialization({ sessionUri: session, sessionId, workingDirectory: workingDirectories?.[0], config: owner.config.values, prompt, githubToken }); + this.assertChatInitialization(chat); + this._chatOwner(chat, true); + if (!workingDirectories?.[0] || !isEqual(resolved, workingDirectories[0])) { + workingDirectories = [resolved, ...(workingDirectories?.slice(1) ?? [])]; + } + } + await provider.initializeChat(chat, { ...lease, workingDirectories }); + this.assertChatInitialization(chat); + lease.assertValid(); + if (!this._state.getSnapshot(chat) && !await this._state.resolveChatState(chat)) { + throw new CancellationError(); + } + lease.assertValid(); + if (this._provider(chat, true) !== provider) { + throw new CancellationError(); + } + const snapshot = provider.getSnapshot(chat); + if (!snapshot) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The exact canvas registry is not ready.'); + } + await this._applySnapshot(provider, snapshot); + lease.commit(); + } finally { + lease.dispose(); + } + } + + private _list(params: ListCanvasTypesParams, cursors: Map): ListCanvasTypesResult { + const owner = this._chatOwner(params.channel, false); + const snapshot = this._providers.getProvider(owner.provider)?.canvases?.getSnapshot(params.channel); + const types = snapshot?.types ?? []; + for (const type of types) { + validateCanvasType(type); + } + const signature = JSON.stringify([snapshot?.generation, types]); + const cursor = params.cursor ? cursors.get(params.cursor) : undefined; + const offset = params.cursor === undefined ? 0 : cursor?.chat === params.channel && cursor.signature === signature ? cursor.offset : NaN; + if (!Number.isSafeInteger(offset) || offset < 0 || offset > types.length) { + throw invalidCanvasParams('The canvas catalogue cursor is no longer valid.'); + } + const end = Math.min(types.length, offset + (params.limit ?? 64)); + let nextCursor: string | undefined; + if (end < types.length) { + nextCursor = generateUuid(); + if (cursors.size >= 128) { + cursors.delete(cursors.keys().next().value!); + } + cursors.set(nextCursor, { chat: params.channel, signature, offset: end }); + } + return { types: structuredClone(types.slice(offset, end)), ...(nextCursor ? { nextCursor } : {}) }; + } + + private async _open(params: OpenCanvasParams, operation: IAgentCanvasOperation): Promise { + const provider = this._provider(params.identity.chat, true); + this._assertInstanceNamespace(provider, params.identity); + if (!provider.getSnapshot(params.identity.chat)) { + await this._initializeChat(params.identity.chat, operation); + } + if (provider.prepare) { + await raceCancellationError(provider.prepare(params.identity, { + ...operation, + willExecute: () => { + if (this._store.isDisposed || operation.token.isCancellationRequested || this._provider(params.identity.chat, true) !== provider) { + throw new CancellationError(); + } + operation.willExecute(); + }, + }), operation.token); + } + this._assertTrusted(provider, params.identity); + const snapshot = provider.getSnapshot(params.identity.chat); + const declaration = snapshot?.types.find(type => type.canvasType === params.identity.canvasType && canvasSourceKey(type.source) === canvasSourceKey(params.identity.source)); + if (!snapshot || !declaration) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'The canvas type is not in this chat\'s live catalogue. Initialize its eligible runtime explicitly first.'); + } + validateCanvasType(declaration); + await this._validateInput(provider, params.identity, declaration.openInputSchema, declaration.openInputSchemaRef, params.input); + const current = this._find(params.identity); + this._assertInstanceNamespace(provider, params.identity); + if (!current && this._state.getCanvasState(params.canvas)) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The requested canvas resource belongs to another identity.'); + } + if (!current && this._state.getChatCanvasStates(params.identity.chat).length >= 64) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'This chat has reached its canvas membership limit.'); + } + this._assertCurrent(provider, params.identity, snapshot.generation, operation); + this._closed.delete(canvasIdentityKey(params.identity)); + const resource = current?.resource ?? params.canvas; + if (!current) { + this._state.registerCanvas({ + resource, identity: { ...this._identity({ ...params.identity, source: declaration.source }), incarnation: generateUuid() }, title: params.title, + ...(params.icon === undefined ? {} : { icon: params.icon }), + trust: provider.getTrust(params.identity.chat, declaration.source), availability: { status: CanvasAvailabilityStatus.Loading }, revision: 1, + }); + } + let started = false; + try { + await this.persistChat(params.identity.chat); + const instance = await raceCancellationError(provider.open(params, { + ...operation, + willExecute: () => { + this._assertCurrent(provider, params.identity, snapshot.generation, operation); + operation.willExecute(); + started = true; + }, + }), operation.token); + this._assertCurrent(provider, params.identity, snapshot.generation, operation); + if (canvasIdentityKey(instance.identity) !== canvasIdentityKey(params.identity)) { + throw new CanvasOperationIndeterminateError(); + } + this._record(provider, snapshot.generation, instance, resource); + await this.persistChat(params.identity.chat); + return { canvas: canvasEntry(this._require(resource)) }; + } catch (error) { + if (!started && !current) { + this._state.removeCanvas(resource); + } else if (started && this._state.getCanvasState(resource)) { + this._availability(resource, { status: CanvasAvailabilityStatus.Failed, error: { errorType: 'canvasOpenIndeterminate', message: 'Canvas open did not settle. Reconcile the provider before trying again.' } }); + } + await this.persistChat(params.identity.chat); + throw error; + } + } + + private async _invoke(params: InvokeCanvasActionParams, operation: IAgentCanvasOperation): Promise { + const state = this._require(params.channel); + const provider = this._provider(state.identity.chat, true); + this._assertIncarnation(state, params.incarnation); + this._assertTrusted(provider, state.identity); + const action = state.availability.status === CanvasAvailabilityStatus.Ready ? state.availability.actions.find(action => action.id === params.actionId) : undefined; + if (!action) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'The canvas does not currently declare this action.'); + } + validateCanvasActions([action]); + await this._validateInput(provider, state.identity, action.inputSchema, action.inputSchemaRef, params.input); + const generation = this._generations.get(state.resource); + const result = await raceCancellationError(provider.invoke(state, params, { + ...operation, + willExecute: () => { + this._assertIncarnation(this._require(state.resource), params.incarnation); + this._assertCurrent(provider, state.identity, generation, operation); + const live = provider.getSnapshot(state.identity.chat)?.instances.find(instance => canvasIdentityKey(instance.identity) === canvasIdentityKey(state.identity)); + const liveAction = live?.availability.status === CanvasAvailabilityStatus.Ready ? live.availability.actions.find(candidate => candidate.id === params.actionId) : undefined; + if (!equals(liveAction, action)) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The live canvas action declaration changed.'); + } + operation.willExecute(); + }, + }), operation.token); + this._assertIncarnation(this._require(state.resource), params.incarnation); + this._assertCurrent(provider, state.identity, generation, operation); + if (!isBoundedCanvasJson(result, CANVAS_RESULT_MAX_LENGTH)) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The action ran but its result exceeds the inline canvas contract. The provider must return an out-of-band reference.', { outcome: 'indeterminate' }); + } + return { result }; + } + + private async _resolve(params: ResolveCanvasSourceParams, clientId: string, token: CancellationToken): Promise { + const state = this._require(params.channel); + const owner = this._chatOwner(state.identity.chat, false); + const provider = this._providers.getProvider(owner.provider)?.canvases; + const generation = this._generations.get(state.resource); + let source: CanvasSourcePresentation | undefined; + if (provider && (state.availability.status === CanvasAvailabilityStatus.Ready || state.availability.status === CanvasAvailabilityStatus.Empty)) { + this._assertTrusted(provider, state.identity); + source = await provider.resolve(state, clientId, token); + } + if (token.isCancellationRequested || this._store.isDisposed) { + throw new CancellationError(); + } + const current = this._require(params.channel); + if (source && provider) { + this._assertTrusted(provider, current.identity); + if (this._provider(current.identity.chat) !== provider || generation !== provider.getSnapshot(current.identity.chat)?.generation || current.revision !== state.revision || current.identity.incarnation !== state.identity.incarnation) { + source = undefined; + } else { + if (typeof source.url !== 'string' || source.url.length > 64 * 1024 || !URL.canParse(source.url) + || source.expiresAt !== undefined && (typeof source.expiresAt !== 'string' || source.expiresAt.length > 64 || !Number.isFinite(Date.parse(source.expiresAt)))) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'The provider returned an invalid canvas presentation.'); + } + const url = new URL(source.url); + if (!['http:', 'https:', 'file:'].includes(url.protocol) || url.username || url.password) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'The provider returned an unsupported canvas presentation.'); + } + source = source.expiresAt !== undefined && Date.parse(source.expiresAt) <= Date.now() ? undefined : { + url: source.url, ...(source.expiresAt === undefined ? {} : { expiresAt: source.expiresAt }), + }; + } + } + return { availability: current.availability.status, incarnation: current.identity.incarnation, revision: current.revision, ...(source ? { source } : {}) }; + } + + private async _close(params: CloseCanvasParams, operation: IAgentCanvasOperation): Promise { + const state = this._state.getCanvasState(params.channel); + if (!state) { + return; + } + if (state.revision !== params.revision) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas membership changed before close.'); + } + const owner = this._chatOwner(state.identity.chat, true); + const provider = this._providers.getProvider(owner.provider)?.canvases; + const snapshot = provider?.getSnapshot(state.identity.chat); + const instance = snapshot?.instances.find(instance => canvasIdentityKey(instance.identity) === canvasIdentityKey(state.identity)); + if (snapshot && instance && this._closed.size >= 4096) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'Canvas close bookkeeping is full. Explicit backing recovery is required.'); + } + if (provider?.available && snapshot && instance && provider.getTrust(state.identity.chat, state.identity.source).status === CanvasTrustStatus.Trusted) { + await raceCancellationError(provider.close(state, { + ...operation, + willExecute: () => { + if (this._require(params.channel).revision !== params.revision) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas membership changed before close.'); + } + this._assertCurrent(provider, state.identity, snapshot.generation, operation); + operation.willExecute(); + }, + }), operation.token); + const latest = provider.getSnapshot(state.identity.chat); + const replacement = latest?.instances.find(candidate => canvasIdentityKey(candidate.identity) === canvasIdentityKey(state.identity)); + if (latest && (latest.generation !== snapshot.generation || replacement && (replacement.generation ?? latest.generation) !== (instance.generation ?? snapshot.generation))) { + throw new CanvasOperationIndeterminateError(); + } + } + if (snapshot && instance) { + this._closed.set(canvasIdentityKey(state.identity), { + chat: state.identity.chat, generation: snapshot.generation, instanceGeneration: instance.generation ?? snapshot.generation, + }); + } + if (operation.token.isCancellationRequested) { + throw new CanvasOperationIndeterminateError(); + } + operation.willExecute(); + this._availability(state.resource, { status: CanvasAvailabilityStatus.NotLoaded }); + await this._persistChat(state.identity.chat, state.resource); + this._state.removeCanvas(state.resource); + this._generations.delete(state.resource); + this._instanceGenerations.delete(state.resource); + } + + private async _restart(params: RestartCanvasProviderParams, operation: IAgentCanvasOperation): Promise { + const state = this._require(params.channel); + this._assertIncarnation(state, params.incarnation); + const provider = this._provider(state.identity.chat, true); + const generation = provider.getSnapshot(state.identity.chat)?.generation; + await raceCancellationError(provider.restart(state, { + ...operation, + willExecute: () => { + this._assertIncarnation(this._require(state.resource), params.incarnation); + if (operation.token.isCancellationRequested || this._provider(state.identity.chat, true) !== provider) { + throw new CancellationError(); + } + operation.willExecute(); + for (const canvas of this._state.getChatCanvasStates(state.identity.chat)) { + this._availability(canvas.resource, { status: CanvasAvailabilityStatus.Loading }); + } + }, + }), operation.token); + const snapshot = provider.getSnapshot(state.identity.chat); + if (!snapshot || snapshot.generation === generation || operation.token.isCancellationRequested) { + throw new CanvasOperationIndeterminateError(); + } + await this._applySnapshot(provider, snapshot); + } + + private _observe(provider: IAgent, snapshot: IAgentCanvasSnapshot): void { + if (!parseChatUri(snapshot.chat) || snapshot.instances.length > 64 || snapshot.types.length > 1024) { + this._logService.warn('[Canvases] Rejected an invalid provider observation.'); + this._rejectObservation(provider, snapshot.chat); + return; + } + if (!this._hasChat(snapshot.chat)) { + const initialization = this._initializing.get(snapshot.chat); + const owner = parseChatUri(snapshot.chat)?.session; + if (!initialization || initialization.token.isCancellationRequested || !owner || this._providers.getProviderForSession(owner) !== provider) { + return; + } + try { + const length = JSON.stringify(snapshot).length; + if (length <= 8 * 1024 * 1024 && (this._pending.has(snapshot.chat) || this._pending.size < 128) + && length + [...this._pending].reduce((sum, [chat, entry]) => sum + (chat === snapshot.chat ? 0 : entry.length), 0) <= 16 * 1024 * 1024) { + const store = new DisposableStore(); + this._pending.set(snapshot.chat, { provider, snapshot, length, dispose: () => store.dispose() }); + store.add(disposableTimeout(() => this._pending.deleteAndDispose(snapshot.chat), 120_000)); + } + } catch { + this._logService.warn('[Canvases] Rejected an invalid early provider observation.'); + } + return; + } + void this._queue.queue(snapshot.chat, async () => { + const live = provider.canvases?.getSnapshot(snapshot.chat); + if (!provider.canvases || this._provider(snapshot.chat) !== provider.canvases || (live ? live !== snapshot : snapshot.instances.length > 0 || snapshot.types.length > 0)) { + return; + } + await this._applySnapshot(provider.canvases, snapshot); + }).catch(() => { + this._logService.warn('[Canvases] A provider observation could not be projected.'); + this._rejectObservation(provider, snapshot.chat); + }); + } + + private _rejectObservation(provider: IAgent, chat: string): void { + if (this._store.isDisposed || !this._hasChat(chat) || this._providers.getProvider(this._chatOwner(chat, false).provider) !== provider) { + return; + } + for (const state of this._state.getChatCanvasStates(chat)) { + this._instanceGenerations.set(state.resource, generateUuid()); + this._availability(state.resource, { status: CanvasAvailabilityStatus.Failed, error: { errorType: 'canvasObservationRejected', message: localize('canvas.observationRejected', "The canvas state could not be reconciled. Explicit backing recovery is required.") } }); + } + } + + private async _applySnapshot(provider: IAgentCanvases, snapshot: IAgentCanvasSnapshot): Promise { + if (this._store.isDisposed || !this._hasChat(snapshot.chat)) { + return; + } + const identities = new Set(); + const instanceIds = new Set(); + const declaredTypes = new Set(); + const closedKeys = new Set(); + if (typeof snapshot.generation !== 'string' || !snapshot.generation.length || snapshot.generation.length > 256 + || snapshot.instances.length > 64 || snapshot.types.length > 1024 || (snapshot.closed?.length ?? 0) > 1024) { + throw invalidCanvasParams('The provider snapshot exceeds the canvas bound.'); + } + for (const type of snapshot.types) { + validateCanvasType(type); + const key = JSON.stringify([canvasSourceKey(type.source), type.canvasType]); + if (declaredTypes.has(key)) { + throw invalidCanvasParams('The provider snapshot repeats a canvas type.'); + } + declaredTypes.add(key); + } + for (const closed of snapshot.closed ?? []) { + if (!isCanvasIdentity(closed) || closed.chat !== snapshot.chat || closedKeys.has(canvasIdentityKey(closed))) { + throw invalidCanvasParams('The provider snapshot contains an invalid native close.'); + } + closedKeys.add(canvasIdentityKey(closed)); + } + const memberships = new Set(this._state.getChatCanvasStates(snapshot.chat).map(state => canvasIdentityKey(state.identity)).filter(key => !closedKeys.has(key))); + for (const instance of snapshot.instances) { + this._validateInstance(instance, snapshot.chat); + this._assertInstanceNamespace(provider, instance.identity, closedKeys); + const key = canvasIdentityKey(instance.identity); + if (identities.has(key) || closedKeys.has(key) || provider.instanceIdScope === 'chat' && instanceIds.has(instance.identity.instanceId)) { + throw invalidCanvasParams('The provider snapshot repeats a native canvas identity.'); + } + identities.add(key); + instanceIds.add(instance.identity.instanceId); + const closed = this._closed.get(key); + if (declaredTypes.has(JSON.stringify([canvasSourceKey(instance.identity.source), instance.identity.canvasType])) + && provider.getTrust(snapshot.chat, instance.identity.source).status === CanvasTrustStatus.Trusted + && !(closed && closed.generation === snapshot.generation && closed.instanceGeneration === (instance.generation ?? snapshot.generation))) { + memberships.add(key); + } + } + if (memberships.size > 64) { + throw invalidCanvasParams('The provider observation exceeds the canvas membership bound.'); + } + const observed = new Set(); + for (const closed of snapshot.closed ?? []) { + this._closed.delete(canvasIdentityKey(closed)); + const current = this._find(closed); + if (current) { + this._availability(current.resource, { status: CanvasAvailabilityStatus.NotLoaded }); + this._state.removeCanvas(current.resource); + this._generations.delete(current.resource); + this._instanceGenerations.delete(current.resource); + } + } + for (const instance of snapshot.instances) { + const key = canvasIdentityKey(instance.identity); + observed.add(key); + const closed = this._closed.get(key); + if (closed) { + if (closed.generation === snapshot.generation && closed.instanceGeneration === (instance.generation ?? snapshot.generation)) { + continue; + } + this._closed.delete(key); + } + if (provider.getTrust(snapshot.chat, instance.identity.source).status !== CanvasTrustStatus.Trusted && !this._find(instance.identity)) { + continue; + } + const declaration = snapshot.types.find(type => type.canvasType === instance.identity.canvasType && canvasSourceKey(type.source) === canvasSourceKey(instance.identity.source)); + if (!declaration) { + const current = this._find(instance.identity); + if (current) { + this._availability(current.resource, { status: CanvasAvailabilityStatus.NotLoaded }); + } + continue; + } + validateCanvasType(declaration); + this._record(provider, snapshot.generation, instance); + } + for (const state of this._state.getChatCanvasStates(snapshot.chat)) { + const trust = provider.getTrust(snapshot.chat, state.identity.source); + if (!equals(state.trust, trust)) { + this._state.dispatchServerAction(state.resource, { type: ActionType.CanvasTrustChanged, trust, revision: state.revision + 1 }); + } + if (!observed.has(canvasIdentityKey(state.identity))) { + this._availability(state.resource, { status: CanvasAvailabilityStatus.NotLoaded }); + } + } + await this.persistChat(snapshot.chat); + } + + private _record(provider: IAgentCanvases, generation: string, instance: IAgentCanvasInstance, preferredResource?: string): void { + this._validateInstance(instance, instance.identity.chat); + this._assertInstanceNamespace(provider, instance.identity); + let current = this._find(instance.identity); + if (typeof instance.title !== 'string' || instance.title.length > 4096 || instance.icon !== undefined && !isCanvasIcon(instance.icon) + || !current && this._state.getChatCanvasStates(instance.identity.chat).length >= 64) { + throw invalidCanvasParams('The native canvas metadata exceeds its bound.'); + } + const resource = current?.resource ?? preferredResource ?? `${AHP_CANVAS_SCHEME}:/${generateUuid()}`; + if (!current) { + this._state.registerCanvas({ + resource, identity: { ...this._identity(instance.identity), incarnation: generateUuid() }, title: instance.title, + ...(instance.icon === undefined ? {} : { icon: structuredClone(instance.icon) }), + trust: provider.getTrust(instance.identity.chat, instance.identity.source), availability: structuredClone(instance.availability), revision: 1, + }); + } else { + const previousGeneration = this._instanceGenerations.get(resource); + if (previousGeneration !== undefined && previousGeneration !== (instance.generation ?? generation)) { + this._availability(resource, { status: CanvasAvailabilityStatus.Loading }); + current = this._require(resource); + this._state.dispatchServerAction(resource, { type: ActionType.CanvasIncarnationChanged, incarnation: generateUuid(), revision: current.revision + 1 }); + } + current = this._require(resource); + if (current.title !== instance.title) { + this._state.dispatchServerAction(resource, { type: ActionType.CanvasTitleChanged, title: instance.title, revision: current.revision + 1 }); + } + current = this._require(resource); + if (!equals(current.icon, instance.icon)) { + this._state.dispatchServerAction(resource, { type: ActionType.CanvasIconChanged, icon: instance.icon === undefined ? null : structuredClone(instance.icon), revision: current.revision + 1 }); + } + this._availability(resource, instance.availability); + current = this._require(resource); + const trust = provider.getTrust(instance.identity.chat, instance.identity.source); + if (!equals(trust, current.trust)) { + this._state.dispatchServerAction(resource, { type: ActionType.CanvasTrustChanged, trust, revision: current.revision + 1 }); + } + } + this._generations.set(resource, generation); + this._instanceGenerations.set(resource, instance.generation ?? generation); + const session = parseChatUri(instance.identity.chat)?.session; + if (session) { + this._state.markSessionUsed(session); + } + } + + private _availability(resource: string, availability: CanvasAvailabilityState): void { + const current = this._require(resource); + if (!equals(current.availability, availability)) { + this._state.dispatchServerAction(resource, { type: ActionType.CanvasAvailabilityChanged, availability: structuredClone(availability), revision: current.revision + 1 }); + } + } + + private _hasChat(chat: string): boolean { + const session = parseChatUri(chat)?.session; + return !!session && this._state.getSessionState(session)?.chats.some(candidate => candidate.resource === chat) === true; + } + + private _provider(chat: string, writable = false): IAgentCanvases { + const state = this._chatOwner(chat, writable); + const provider = this._providers.getProvider(state.provider)?.canvases; + if (!provider) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'The owning provider does not support canvases.'); + } + return provider; + } + + private _chatOwner(chat: string, writable: boolean): SessionState { + const parsed = parseChatUri(chat); + const state = parsed ? this._state.getSessionState(parsed.session) : undefined; + const summary = state?.chats.find(candidate => candidate.resource === chat); + if (!state || !summary) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'The canvas backing chat is not registered.'); + } + if (writable && isChatReadOnly(summary.interactivity, (state.status & SessionStatus.IsArchived) !== 0)) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'The canvas backing chat is read-only or archived.'); + } + return state; + } + + private _assertInstanceNamespace(provider: IAgentCanvases, identity: CanvasIdentityKey, closed?: ReadonlySet): void { + if (provider.instanceIdScope === 'chat' && this._state.getChatCanvasStates(identity.chat).some(state => !closed?.has(canvasIdentityKey(state.identity)) && state.identity.instanceId === identity.instanceId && canvasIdentityKey(state.identity) !== canvasIdentityKey(identity))) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The native instance ID is already owned by another source or canvas type in this chat.'); + } + } + + private _validateInstance(instance: IAgentCanvasInstance, chat: string): void { + if (!isCanvasIdentity(instance.identity) || instance.identity.chat !== chat + || typeof instance.title !== 'string' || instance.title.length > 4096 || instance.icon !== undefined && !isCanvasIcon(instance.icon) + || instance.generation !== undefined && (typeof instance.generation !== 'string' || !instance.generation.length || instance.generation.length > 512) + || !isCanvasRecord(instance.availability) || !['unsupported', 'notLoaded', 'loading', 'empty', 'ready', 'failed'].includes(instance.availability.status)) { + throw invalidCanvasParams('The provider snapshot contains invalid canvas metadata.'); + } + if (instance.availability.status === CanvasAvailabilityStatus.Ready) { + validateCanvasActions(instance.availability.actions); + } else if (instance.availability.status === CanvasAvailabilityStatus.Failed + && (!isCanvasRecord(instance.availability.error) || typeof instance.availability.error.message !== 'string' + || instance.availability.error.message.length > 8192 || !isBoundedCanvasJson(instance.availability.error, 16384))) { + throw invalidCanvasParams('The provider snapshot contains an invalid canvas failure.'); + } + } + + private _find(identity: CanvasIdentityKey): CanvasState | undefined { + const key = canvasIdentityKey(identity); + return this._state.getChatCanvasStates(identity.chat).find(state => canvasIdentityKey(state.identity) === key); + } + + private _require(resource: string): CanvasState { + const state = this._state.getCanvasState(resource); + if (!state) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'Canvas membership was not found.'); + } + return state; + } + + private _assertTrusted(provider: IAgentCanvases, identity: CanvasIdentityKey): void { + if (provider.getTrust(identity.chat, identity.source).status !== CanvasTrustStatus.Trusted) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Canvas source execution has not been admitted.'); + } + } + + private _assertIncarnation(state: CanvasState, incarnation: string): void { + if (state.identity.incarnation !== incarnation) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas endpoint incarnation changed.'); + } + } + + private _assertCurrent(provider: IAgentCanvases, identity: CanvasIdentityKey, generation: string | undefined, operation: IAgentCanvasOperation): void { + if (this._store.isDisposed || operation.token.isCancellationRequested) { + throw new CancellationError(); + } + if (this._provider(identity.chat, true) !== provider || provider.getSnapshot(identity.chat)?.generation !== generation) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas backing changed.'); + } + this._assertTrusted(provider, identity); + } + + private async _validateInput(provider: IAgentCanvases, identity: CanvasIdentityKey, schema: CanvasActionDeclaration['inputSchema'], reference: string | undefined, input: unknown): Promise { + const resolved = reference === undefined ? schema : await provider.resolveSchema?.(identity.chat, identity.source, reference); + if (reference !== undefined && resolved === undefined) { + throw invalidCanvasParams('The canvas schema reference is not available from its owning provider.'); + } + if (resolved !== undefined) { + if (!isCanvasRecord(resolved) || !isBoundedCanvasJson(resolved, 1024 * 1024)) { + throw invalidCanvasParams('The resolved canvas schema is invalid or exceeds the supported reference bound.'); + } + await provider.validateInput(identity.chat, identity.source, resolved, input); + } + } + + async loadChat(chat: string): Promise { + const parsed = parseChatUri(chat); + if (!parsed) { + return []; + } + const reference = await this._sessionData.tryOpenDatabase(URI.parse(parsed.session)); + if (!reference) { + return []; + } + try { + if (await reference.object.getMetadata(retainedSessionStorageKey) === 'true') { + this._rememberRetention(parsed.session); + } + const serialized = await reference.object.getMetadata(this._storageKey(chat)); + const entries: unknown = serialized && serialized.length <= 1024 * 1024 ? JSON.parse(serialized) : []; + if (!Array.isArray(entries) || entries.length > 64) { + throw invalidCanvasParams('Invalid persisted canvas membership.'); + } + const identities = new Set(); + const resources = new Set(); + return entries.map((entry): CanvasState => { + if (!isCanvasRecord(entry) || !isCanvasResource(entry.resource) || !isCanvasIdentity(entry.identity) || entry.identity.chat !== chat + || typeof entry.title !== 'string' || entry.title.length > 4096 || typeof entry.revision !== 'number' || !Number.isSafeInteger(entry.revision) || entry.revision < 0 || entry.revision >= Number.MAX_SAFE_INTEGER + || entry.icon !== undefined && !isCanvasIcon(entry.icon)) { + throw invalidCanvasParams('Invalid persisted canvas identity.'); + } + const key = canvasIdentityKey(entry.identity); + if (identities.has(key) || resources.has(entry.resource)) { + throw invalidCanvasParams('Duplicate persisted canvas identity.'); + } + identities.add(key); + resources.add(entry.resource); + return { + resource: entry.resource, identity: { ...this._identity(entry.identity), incarnation: generateUuid() }, title: entry.title, + ...(isCanvasIcon(entry.icon) ? { icon: entry.icon } : {}), + trust: { status: CanvasTrustStatus.Pending }, availability: { status: CanvasAvailabilityStatus.NotLoaded }, revision: entry.revision + 1, + }; + }); + } catch { + this._logService.warn('[Canvases] Could not restore invalid canvas membership.'); + return []; + } finally { + reference.dispose(); + } + } + + persistChat(chat: string): Promise { + if (!this._hasChat(chat)) { + this._pending.deleteAndDispose(chat); + this.discardPendingAttachments(chat); + for (const [key, closed] of this._closed) { + if (closed.chat === chat) { + this._closed.delete(key); + } + } + for (const resource of this._generations.keys()) { + if (!this._state.getCanvasState(resource)) { + this._generations.delete(resource); + this._instanceGenerations.delete(resource); + } + } + } + return this._persistChat(chat); + } + + private _persistChat(chat: string, omittedResource?: string): Promise { + const parsed = parseChatUri(chat); + if (!parsed) { + return Promise.resolve(); + } + const membership = this._state.getChatCanvasStates(chat).filter(state => state.resource !== omittedResource).map(state => ({ + resource: state.resource, identity: this._identity(state.identity), title: state.title, revision: state.revision, + ...(state.icon ? { icon: state.icon } : {}), + })); + return this._writes.queue(chat, async () => { + if (!this._state.getSessionState(parsed.session)) { + return; + } + const reference = this._hasChat(chat) ? this._sessionData.openDatabase(URI.parse(parsed.session)) : await this._sessionData.tryOpenDatabase(URI.parse(parsed.session)); + if (!reference) { + return; + } + try { + await reference.object.setMetadata(this._storageKey(chat), JSON.stringify(membership)); + } finally { + reference.dispose(); + } + }); + } + + private _storageKey(chat: string): string { + return `canvases.v1.${chat}`; + } + + private _identity(identity: CanvasIdentityKey): CanvasIdentityKey { + const source = identity.source.kind === CanvasSourceKind.Extension + ? { kind: CanvasSourceKind.Extension, extensionId: identity.source.extensionId } as const + : { kind: CanvasSourceKind.Package, sourceId: identity.source.sourceId, packageName: identity.source.packageName } as const; + return { chat: identity.chat, source, canvasType: identity.canvasType, instanceId: identity.instanceId }; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts index 0022261152bc7..ddbb98018d50e 100644 --- a/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts +++ b/src/vs/platform/agentHost/node/agentHostClientConnectionService.ts @@ -5,7 +5,8 @@ import { Disposable, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; -import type { IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; +import type { IAgentHostCanvasApprovalRequest, IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; +import type { CancellationToken } from '../../../base/common/cancellation.js'; export const AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION = 30_000 * 10; @@ -19,7 +20,9 @@ export interface IAgentHostClientConnectionSource { hasSeenClient(clientId: string): boolean; isClientConnected(clientId: string): boolean; getConnectedClientTransportCounts(): ReadonlyMap; + getSubscribedClients?(resource: string): readonly string[]; requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; + requestCanvasApproval?(clientId: string, request: IAgentHostCanvasApprovalRequest, token: CancellationToken): Promise; } export const IAgentHostClientConnectionService = createDecorator('agentHostClientConnectionService'); @@ -30,7 +33,9 @@ export interface IAgentHostClientConnectionService { hasSeenClient(clientId: string): boolean; isClientConnected(clientId: string): boolean; getConnectionCounts(clientId: string): IAgentHostClientConnectionCounts; + getSubscribedClients(resource: string): readonly string[]; requestWorkspaceTrust(clientId: string, request: IAgentHostWorkspaceTrustRequest): Promise; + requestCanvasApproval(clientId: string, request: IAgentHostCanvasApprovalRequest, token: CancellationToken): Promise; } export class AgentHostClientConnectionService extends Disposable implements IAgentHostClientConnectionService { @@ -96,4 +101,17 @@ export class AgentHostClientConnectionService extends Disposable implements IAge } return Promise.reject(new Error(`Cannot request workspace trust because client ${clientId} is not connected.`)); } + + requestCanvasApproval(clientId: string, request: IAgentHostCanvasApprovalRequest, token: CancellationToken): Promise { + for (const source of this._sources) { + if (source.isClientConnected(clientId)) { + return source.requestCanvasApproval?.(clientId, request, token) ?? Promise.resolve(false); + } + } + return Promise.resolve(false); + } + + getSubscribedClients(resource: string): readonly string[] { + return [...new Set([...this._sources].flatMap(source => source.getSubscribedClients?.(resource) ?? []))]; + } } diff --git a/src/vs/platform/agentHost/node/agentHostServices.ts b/src/vs/platform/agentHost/node/agentHostServices.ts index ccf4eb789f7e5..b20b9c12cb68b 100644 --- a/src/vs/platform/agentHost/node/agentHostServices.ts +++ b/src/vs/platform/agentHost/node/agentHostServices.ts @@ -41,6 +41,7 @@ import { AgentHostChangesetOperationService } from './agentHostChangesetOperatio import { AgentHostChangesetService } from './agentHostChangesetService.js'; import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js'; import { AgentHostChatContributions } from './agentHostChatContributionsService.js'; +import { AgentHostCanvasesService, IAgentHostCanvasesService } from './agentHostCanvasesService.js'; import { AgentHostCheckpointService } from './agentHostCheckpointService.js'; import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; @@ -100,6 +101,7 @@ export function registerAgentHostCoreServices(services: ServiceCollection, input services.set(IAgentHostCompletions, new SyncDescriptor(AgentHostCompletions)); services.set(IAgentHostTerminalManager, new SyncDescriptor(AgentHostTerminalManager)); services.set(IAgentHostChatContributions, new SyncDescriptor(AgentHostChatContributions)); + services.set(IAgentHostCanvasesService, new SyncDescriptor(AgentHostCanvasesService)); services.set(IAgentHostTurnService, new SyncDescriptor(AgentHostTurnService)); services.set(IAgentHostTelemetryReporter, new SyncDescriptor(AgentHostTelemetryReporter)); services.set(IAgentHostTurnTracker, new SyncDescriptor(AgentHostTurnTracker)); diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 9b686cedc856a..8e7ef13872401 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -5,12 +5,15 @@ import { RunOnceScheduler } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, toDisposable, type IDisposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { hasKey } from '../../../base/common/types.js'; import { ILogService } from '../../log/common/log.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isCanvasAction, isChangesetAction, isAnnotationsAction, isAutomationAction, isAutomationRunAction, isPassiveSessionMetadataAction, type AuthRequiredParams, type ClientAutomationAction, type ClientAutomationRunAction, type ProgressParams, type SessionSummaryChangedParams, type SessionSummaryChanges } from '../common/state/sessionActions.js'; +import type { ChatTurnStartedAction } from '../common/state/protocol/channels-chat/actions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer, automationReducer, automationRunReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isAhpAutomationCatalogChannel, isAhpAutomationRunChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, withSessionStatusFlag, type AutomationState, type AutomationRunState, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; @@ -25,6 +28,9 @@ import { preserveProviderBackedRootConfigValues } from '../common/agentCustomiza import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; import { type IChatSurfaceMeta, readChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; +import { canvasEntry, canvasIdentityKey } from '../common/agentHostCanvasValidation.js'; +import { canvasReducer } from '../common/state/protocol/channels-canvas/reducer.js'; +import type { CanvasState } from '../common/state/protocol/channels-canvas/state.js'; export interface IAgentHostStateManagerOptions { readonly changesetStateRetention?: IAgentHostChangesetStateRetentionOptions; @@ -85,6 +91,7 @@ type RestoredChatResolver = (providerData: string | undefined) => Promise(); + private readonly _pendingChatEntries = new Map(); + private readonly _deferredTurns = new Map void }>(); /** Expanded changeset states, separated from protocol sequencing so cache policy stays local. */ private readonly _changesets: AgentHostChangesetStateCache; + private readonly _canvases = new Map(); /** * Per-channel annotation states for the `/annotations` channel. @@ -300,6 +310,10 @@ export class AgentHostStateManager extends Disposable { readonly onDidChangeSessionStatus: Event<{ session: string; status: SessionStatus }> = this._onDidChangeSessionStatus.event; private readonly _onDidRemoveSession = this._register(new Emitter()); readonly onDidRemoveSession: Event = this._onDidRemoveSession.event; + private readonly _onDidRegisterChat = this._register(new Emitter()); + readonly onDidRegisterChat: Event = this._onDidRegisterChat.event; + private readonly _onDidMaterializeChat = this._register(new Emitter()); + readonly onDidMaterializeChat = this._onDidMaterializeChat.event; private readonly _onDidChangeSessionTitle = this._register(new Emitter<{ session: string; title: string }>()); readonly onDidChangeSessionTitle: Event<{ session: string; title: string }> = this._onDidChangeSessionTitle.event; @@ -407,7 +421,7 @@ export class AgentHostStateManager extends Disposable { return undefined; } const chatUri = isChat ? sessionOrChat : buildDefaultChatUri(session); - return mergeSessionWithDefaultChat(entry.state, this._chatEntries.get(chatUri)?.state); + return mergeSessionWithDefaultChat(entry.state, this.getChatState(chatUri)); } /** @@ -428,7 +442,7 @@ export class AgentHostStateManager extends Disposable { } /** Permanently marks a session as used, so it is never auto-collected. */ - private _markSessionUsed(session: URI): void { + markSessionUsed(session: URI): void { const entry = this._sessionStates.get(session); if (entry) { entry.use = SessionUse.Used; @@ -508,7 +522,33 @@ export class AgentHostStateManager extends Disposable { /** Returns already-hydrated state without triggering resolution or I/O. */ getChatState(chat: URI): ChatState | undefined { - return this._chatEntries.get(chat)?.state; + return this._chatEntries.get(chat)?.state ?? this._pendingChatEntries.get(chat)?.state; + } + + getChatGeneration(chat: URI): string | undefined { + return (this._chatEntries.get(chat) ?? this._pendingChatEntries.get(chat))?.generation; + } + + /** Reserves routable state during host-owned creation without publishing catalog membership. */ + beginPendingChat(chat: URI): IDisposable { + const existing = this._chatEntries.get(chat); + if (existing?.state || this._pendingChatEntries.has(chat)) { + throw new Error('The chat already has a state owner.'); + } + const session = parseRequiredSessionUriFromChatUri(chat); + const owner = this._sessionStates.get(session); + const now = new Date().toISOString(); + const summary = existing?.summary ?? createDefaultChatSummary(owner ? this._toSummary(session, owner) : { + resource: session, provider: session.slice(0, session.indexOf(':')), title: '', status: SessionStatus.Idle, createdAt: now, modifiedAt: now, + }, chat); + const entry: IChatEntry = { generation: existing?.generation ?? generateUuid(), session, summary, state: { ...createChatState(summary), draft: existing?.draft }, valid: true }; + this._pendingChatEntries.set(chat, entry); + return toDisposable(() => { + if (this._pendingChatEntries.get(chat) === entry) { + entry.valid = false; + this._pendingChatEntries.delete(chat); + } + }); } /** @@ -552,10 +592,21 @@ export class AgentHostStateManager extends Disposable { throw new Error(`Restored chat was invalidated while resolving: ${chat}`); } if (!entry.state) { - entry.state = { ...createChatState(entry.summary), turns: restored.turns, draft: restored.draft ?? entry.draft }; + const pending = this._pendingChatEntries.get(chat)?.state; + const observed = new Set([...(pending?.turns.map(turn => turn.id) ?? []), pending?.activeTurn?.id]); + entry.state = { + ...createChatState(entry.summary), ...pending, title: entry.summary.title, + turns: [...restored.turns.filter(turn => !observed.has(turn.id)), ...(pending?.turns ?? [])], + draft: pending?.draft ?? restored.draft ?? entry.draft, + }; + this._pendingChatEntries.delete(chat); entry.resolver = undefined; - if (restored.turns.length > 0) { - this._markSessionUsed(entry.session); + if (entry.state.turns.length > 0 || entry.state.activeTurn) { + this.markSessionUsed(entry.session); + } + this._onDidMaterializeChat.fire(chat); + if (pending) { + this._onChatStateChanged(entry.session, chat, createChatState(entry.summary), entry.state); } } return entry.state; @@ -592,10 +643,11 @@ export class AgentHostStateManager extends Disposable { seedDefaultChatTurns(session: URI, turns: Turn[]): void { const chatState = this._chatEntries.get(buildDefaultChatUri(session))?.state; if (chatState) { - chatState.turns = turns; + const seeded = new Set(turns.map(turn => turn.id)); + chatState.turns = [...turns, ...chatState.turns.filter(turn => !seeded.has(turn.id))]; } if (turns.length > 0) { - this._markSessionUsed(session); + this.markSessionUsed(session); } } @@ -714,6 +766,11 @@ export class AgentHostStateManager extends Disposable { }; } + const canvas = this._canvases.get(resource); + if (canvas) { + return { resource, state: canvas, fromSeq: this._serverSeq }; + } + // Changeset URIs are nested under their session URI; check them // before falling back to the session map so a session whose URI // happens to share a prefix with a changeset never collides. @@ -814,7 +871,8 @@ export class AgentHostStateManager extends Disposable { } const state = createSessionState(summary); - this._sessionStates.set(key, this._newEntry(state, summary, SessionUse.UnusedDraft)); + const entry = this._newEntry(state, summary, SessionUse.UnusedDraft); + this._sessionStates.set(key, entry); this._ensureDefaultChat(key, summary); this._logService.trace(`[AgentHostStateManager] Created session: ${key}`); @@ -824,10 +882,10 @@ export class AgentHostStateManager extends Disposable { // its later flush emit incremental updates and what makes // `markSessionPersisted` a no-op. Provisional sessions // intentionally skip both until they are persisted. - this._emitSessionAdded(summary); + this._emitSessionAdded(this._toSummary(key, entry)); } - return state; + return entry.state; } /** Builds the authoritative {@link ISessionEntry} for a freshly seeded state. */ @@ -1053,7 +1111,8 @@ export class AgentHostStateManager extends Disposable { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready, }; - this._sessionStates.set(key, this._newEntry(state, summary, SessionUse.Used)); + const entry = this._newEntry(state, summary, SessionUse.Used); + this._sessionStates.set(key, entry); this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle); // A session that was previously surfaced (e.g. announced as an // adoptable-legacy session) is already known to clients with a different @@ -1069,7 +1128,7 @@ export class AgentHostStateManager extends Disposable { this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`); - return state; + return entry.state; } /** @@ -1089,10 +1148,14 @@ export class AgentHostStateManager extends Disposable { // Empty title means "inherit the session title"; a persisted independent // rename (`defaultChatTitle`) is seeded back here so it survives restore. const chatSummary: ChatSummary = { ...createDefaultChatSummary(summary, chatUri), title: defaultChatTitle ?? '' }; + const pendingEntry = this._pendingChatEntries.get(chatUri); + const pending = pendingEntry?.state; + this._pendingChatEntries.delete(chatUri); this._chatEntries.set(chatUri, { + generation: pendingEntry?.generation ?? generateUuid(), session: sessionKey, summary: chatSummary, - state: { ...createChatState(chatSummary), turns: turns ?? [], draft }, + state: { ...createChatState(chatSummary), activeTurn: pending?.activeTurn, turns: [...(turns ?? []), ...(pending?.turns ?? [])], draft: pending?.draft ?? draft }, valid: true, }); const entry = this._sessionStates.get(sessionKey); @@ -1106,6 +1169,12 @@ export class AgentHostStateManager extends Disposable { entry.state.chats = [chatSummary]; entry.state.defaultChat = chatUri; } + this._onDidRegisterChat.fire(chatUri); + this._onDidMaterializeChat.fire(chatUri); + if (pending?.activeTurn || pending?.turns.length) { + this.markSessionUsed(sessionKey); + this._onChatStateChanged(sessionKey, chatUri, createChatState(chatSummary), this.getChatState(chatUri)!); + } } /** @@ -1152,15 +1221,25 @@ export class AgentHostStateManager extends Disposable { ...(options?.origin ? { origin: options.origin } : {}), interactivity: options?.interactivity, }; + const pendingEntry = this._pendingChatEntries.get(chatUri); + const pending = pendingEntry?.state; + this._pendingChatEntries.delete(chatUri); this._chatEntries.set(chatUri, { + generation: pendingEntry?.generation ?? generateUuid(), session, summary: chatSummary, - state: { ...createChatState(chatSummary), turns: options?.turns ?? [] }, + state: { ...createChatState(chatSummary), activeTurn: pending?.activeTurn, draft: pending?.draft, turns: [...(options?.turns ?? []), ...(pending?.turns ?? [])] }, providerData: options?.providerData, inheritedTurnId: options?.inheritedTurnId, valid: true, }); this.dispatchServerAction(session, { type: ActionType.SessionChatAdded, summary: chatSummary }); + this._onDidRegisterChat.fire(chatUri); + this._onDidMaterializeChat.fire(chatUri); + if (pending?.activeTurn || pending?.turns.length) { + this.markSessionUsed(session); + this._onChatStateChanged(session, chatUri, createChatState(chatSummary), this.getChatState(chatUri)!); + } return chatSummary; } @@ -1200,6 +1279,7 @@ export class AgentHostStateManager extends Disposable { }; entry.state.chats = [...entry.state.chats, chatSummary]; this._chatEntries.set(chatUri, { + generation: generateUuid(), session, summary: chatSummary, providerData: options.providerData, @@ -1208,6 +1288,7 @@ export class AgentHostStateManager extends Disposable { resolver: options.resolver, valid: true, }); + this._onDidRegisterChat.fire(chatUri); return chatSummary; } @@ -1244,6 +1325,9 @@ export class AgentHostStateManager extends Disposable { // the active set forever, keeping the session permanently "active" // (activeSessions > 0) and leaving changeset operations disabled. this._removeChatActiveTurn(session, chatUri); + for (const canvas of this.getChatCanvasStates(chatUri)) { + this.removeCanvas(canvas.resource); + } this._invalidateChatEntry(chatUri); this.dispatchServerAction(session, { type: ActionType.SessionChatRemoved, chat: chatUri }); } @@ -1330,6 +1414,11 @@ export class AgentHostStateManager extends Disposable { } this._invalidateChatEntry(buildDefaultChatUri(session)); this._sessionStates.delete(session); + for (const [resource, canvas] of this._canvases) { + if (parseRequiredSessionUriFromChatUri(canvas.identity.chat) === session) { + this._canvases.delete(resource); + } + } this._onDidRemoveSession.fire(session); // The announced baseline outlives in-memory state: this is also the // idle-eviction hook, and eviction emits no `sessionRemoved`, so clients @@ -1461,6 +1550,43 @@ export class AgentHostStateManager extends Disposable { * * Returns the supplied changeset URI for caller convenience. */ + getCanvasState(resource: URI): CanvasState | undefined { + return this._canvases.get(resource); + } + + restoreCanvases(chat: URI, canvases: readonly CanvasState[] | undefined): void { + for (const state of canvases ?? []) { + if (state.identity.chat === chat && !this._canvases.has(state.resource)) { + this.registerCanvas(state); + } + } + } + + getChatCanvasStates(chat: URI): readonly CanvasState[] { + return [...this._canvases.values()].filter(canvas => canvas.identity.chat === chat); + } + + registerCanvas(state: CanvasState): void { + const session = parseRequiredSessionUriFromChatUri(state.identity.chat); + if (!this._sessionStates.get(session)?.state.chats.some(chat => chat.resource === state.identity.chat)) { + throw new Error('Cannot register a canvas for an unknown chat.'); + } + const existing = this._canvases.get(state.resource); + if (existing && canvasIdentityKey(existing.identity) !== canvasIdentityKey(state.identity)) { + throw new Error('The canvas resource already belongs to another identity.'); + } + this._canvases.set(state.resource, state); + this.dispatchServerAction(session, { type: ActionType.SessionCanvasSet, canvas: canvasEntry(state) }); + } + + removeCanvas(resource: URI): void { + const state = this._canvases.get(resource); + if (state) { + this._canvases.delete(resource); + this.dispatchServerAction(parseRequiredSessionUriFromChatUri(state.identity.chat), { type: ActionType.SessionCanvasRemoved, resource }); + } + } + registerChangeset(changesetUri: URI, initialStatus: ChangesetStatus = ChangesetStatus.Computing): URI { this._changesets.register(changesetUri, initialStatus); return changesetUri; @@ -1607,7 +1733,32 @@ export class AgentHostStateManager extends Disposable { */ getActiveTurnId(sessionOrChat: URI): string | undefined { const chatUri = isAhpChatChannel(sessionOrChat) ? sessionOrChat : buildDefaultChatUri(sessionOrChat); - return this._chatEntries.get(chatUri)?.state?.activeTurn?.id; + return this.getChatState(chatUri)?.activeTurn?.id; + } + + /** Keeps one genuine requested turn outside active state until its provider observes that turn starting. */ + deferTurn(channel: string, action: ChatTurnStartedAction, origin: ActionOrigin | undefined, clientContext: IAgentHostClientTelemetryContext | undefined, onApplied: () => void): void { + const generation = this.getChatGeneration(channel); + if (!generation || this._deferredTurns.has(channel) || this._deferredTurns.size >= 128 || this.getActiveTurnId(channel)) { + throw new Error('The chat already owns an active or pending turn.'); + } + this._deferredTurns.set(channel, { generation, action, origin, clientContext, onApplied }); + this.markSessionUsed(parseRequiredSessionUriFromChatUri(channel)); + } + + getDeferredTurnId(channel: string): string | undefined { + return this._deferredTurns.get(channel)?.action.turnId; + } + + rejectDeferredTurn(channel: string, reason: string): ChatTurnStartedAction | undefined { + const pending = this._deferredTurns.get(channel); + if (pending) { + this._deferredTurns.delete(channel); + if (pending.origin) { + this.rejectClientAction(channel, pending.action, pending.origin, reason); + } + } + return pending?.action; } // ---- Action dispatch ---------------------------------------------------- @@ -1622,6 +1773,25 @@ export class AgentHostStateManager extends Disposable { * for terminal actions, an expanded changeset URI for changeset actions. */ dispatchServerAction(channel: URI, action: StateAction): void { + const pending = this._deferredTurns.get(channel); + if (pending && isChatAction(action) && hasKey(action, { turnId: true }) && action.turnId === pending.action.turnId) { + if (pending.generation !== this.getChatGeneration(channel)) { + this.rejectDeferredTurn(channel, 'The original chat no longer exists.'); + return; + } + if (this.getActiveTurnId(channel)) { + if (action.type === ActionType.ChatError || action.type === ActionType.ChatTurnCancelled) { + this.rejectDeferredTurn(channel, 'The pending send ended before its runtime turn started.'); + } + return; + } + this._deferredTurns.delete(channel); + this._applyAndEmit(channel, pending.action, pending.origin, pending.clientContext); + pending.onApplied(); + if (action.type === ActionType.ChatTurnStarted) { + return; + } + } this._applyAndEmit(channel, action, undefined); } @@ -1657,6 +1827,12 @@ export class AgentHostStateManager extends Disposable { // ---- Internal ----------------------------------------------------------- private _invalidateChatEntry(chat: URI): void { + this.rejectDeferredTurn(chat, 'The original chat was disposed.'); + const pending = this._pendingChatEntries.get(chat); + if (pending) { + pending.valid = false; + this._pendingChatEntries.delete(chat); + } const entry = this._chatEntries.get(chat); if (entry) { entry.valid = false; @@ -1675,6 +1851,7 @@ export class AgentHostStateManager extends Disposable { } } else { this._chatEntries.set(summary.resource, { + generation: generateUuid(), session, summary, valid: true, @@ -1690,6 +1867,15 @@ export class AgentHostStateManager extends Disposable { private _applyAndEmit(channel: URI, action: StateAction, origin: ActionOrigin | undefined, clientContext?: IAgentHostClientTelemetryContext): unknown { let resultingState: unknown = undefined; + if (isCanvasAction(action)) { + const state = this._canvases.get(channel); + if (!state || origin || !Number.isSafeInteger(action.revision) || action.revision <= state.revision) { + return undefined; + } + const next = canvasReducer(state, action, this._log); + this._canvases.set(channel, next); + resultingState = next; + } if (action.type === ActionType.RootConfigChanged && action.replace) { action = { ...action, @@ -1764,12 +1950,15 @@ export class AgentHostStateManager extends Disposable { const chatAction = action as ChatAction; const sessionKey = parseRequiredSessionUriFromChatUri(channel); - const chatEntry = this._chatEntries.get(channel); + const registered = this._chatEntries.get(channel); + const chatEntry = registered?.state ? registered : this._pendingChatEntries.get(channel); const chat = chatEntry?.state; if (chat && chatEntry && sessionKey !== undefined) { const newChat = chatReducer(chat, chatAction, this._log); chatEntry.state = newChat; - this._onChatStateChanged(sessionKey, channel, chat, newChat); + if (!this._pendingChatEntries.has(channel)) { + this._onChatStateChanged(sessionKey, channel, chat, newChat); + } resultingState = newChat; } else { this._logService.warn(`[AgentHostStateManager] Action for unknown chat: ${channel}, type=${action.type}`); @@ -1845,6 +2034,12 @@ export class AgentHostStateManager extends Disposable { this._logService.trace(`[AgentHostStateManager] Emitting envelope: seq=${envelope.serverSeq}, channel=${envelope.channel}, type=${action.type}${origin ? `, origin=${origin.clientId}:${origin.clientSeq}` : ''}`); this._onDidEmitEnvelope.fire(envelope); + if (isCanvasAction(action)) { + const state = this._canvases.get(channel); + if (state) { + this.dispatchServerAction(parseRequiredSessionUriFromChatUri(state.identity.chat), { type: ActionType.SessionCanvasSet, canvas: canvasEntry(state) }); + } + } return resultingState; } @@ -1890,7 +2085,7 @@ export class AgentHostStateManager extends Disposable { // Any turn activity permanently retires the session's unused-draft // status, so a later truncate-to-zero cannot make it look collectable. if (next.turns.length > 0 || next.activeTurn) { - this._markSessionUsed(sessionKey); + this.markSessionUsed(sessionKey); } // Active turn tracking — derive from the reducer's view of state, // never from raw action turn-ids, so out-of-order lifecycle actions @@ -2054,6 +2249,8 @@ export class AgentHostStateManager extends Disposable { entry.valid = false; } this._chatEntries.clear(); + this._pendingChatEntries.clear(); + this._deferredTurns.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/node/agentHostSubscriptionService.ts b/src/vs/platform/agentHost/node/agentHostSubscriptionService.ts index a5d6f13421575..8fb11f4dec1f7 100644 --- a/src/vs/platform/agentHost/node/agentHostSubscriptionService.ts +++ b/src/vs/platform/agentHost/node/agentHostSubscriptionService.ts @@ -6,12 +6,15 @@ import { ResourceMap } from '../../../base/common/map.js'; import { URI } from '../../../base/common/uri.js'; import { IAgentHostSubscriptionService, resolveAgentHostSession } from '../common/agentHostSubscriptionService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js'; export class AgentHostSubscriptionService implements IAgentHostSubscriptionService { declare readonly _serviceBrand: undefined; private readonly _subscribers = new ResourceMap>(); + constructor(@IAgentHostStateManager private readonly _state?: AgentHostStateManager) { } + get subscribedResources(): Iterable { return this._subscribers.keys(); } @@ -45,9 +48,9 @@ export class AgentHostSubscriptionService implements IAgentHostSubscriptionServi } hasSessionSubscribers(resource: URI): boolean { - const sessionKey = resolveAgentHostSession(resource).toString(); + const sessionKey = resolveAgentHostSession(resource, this._state?.getCanvasState(resource.toString())?.identity.chat).toString(); for (const subscribedResource of this._subscribers.keys()) { - if (resolveAgentHostSession(subscribedResource).toString() === sessionKey) { + if (resolveAgentHostSession(subscribedResource, this._state?.getCanvasState(subscribedResource.toString())?.identity.chat).toString() === sessionKey) { return true; } } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 2fc4c7717346e..6e9582e5bbf71 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -7,7 +7,7 @@ import { open, unlink, type FileHandle } from 'fs/promises'; import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { Barrier, DeferredPromise, disposableTimeout, Limiter, ResourceQueue } from '../../../base/common/async.js'; import { toErrorMessage } from '../../../base/common/errorMessage.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { getExtensionForMimeType, getMediaMime, getMediaOrTextMime } from '../../../base/common/mime.js'; import { Schemas } from '../../../base/common/network.js'; @@ -62,6 +62,8 @@ import { IAgentHostSubscriptionService, resolveAgentHostSession } from '../commo import { AgentSideEffects, type IAgentSideEffectsOptions } from './agentSideEffects.js'; import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentSessionResidency } from './agentSessionResidency.js'; +import { IAgentHostCanvasesService, type IAgentHostCanvasTurnPreparation } from './agentHostCanvasesService.js'; +import type { IAgentCanvasApprovalClient } from '../common/agentHostCanvases.js'; import { IAgentHostSessionOpenTelemetry, type IAgentHostSessionOpenTelemetryScope } from './agentHostSessionOpenTelemetry.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { type IAgentServiceSessionServerToolAccessor, type IChatContextSnapshot, type IRenameTitleResult, type ISessionCreationDefaults, validateRenameTitle } from './shared/sessionServerTools.js'; @@ -621,6 +623,7 @@ export class AgentService extends Disposable implements IAgentService { @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, @IAgentHostStorageService private readonly _storageService: IAgentHostStorageService, + @IAgentHostCanvasesService private readonly _canvases: IAgentHostCanvasesService, ) { super(); this._authService = core.authenticationService; @@ -682,10 +685,17 @@ export class AgentService extends Disposable implements IAgentService { { limit: options.sessionResidencyLimit, releaseRetryMs: options.sessionReleaseRetryMs, - holdsSession: session => this._agentMergeController.holdsSession(session), - onDidReleaseHold: this._agentMergeController.onDidReleaseHold, + holdsSession: session => this._agentMergeController.holdsSession(session) || this._canvases.holdsSession(session), + onDidReleaseHold: Event.any(this._agentMergeController.onDidReleaseHold, this._canvases.onDidReleaseHold), }, )); + this._register(this._canvases.onDidReleaseHold(session => { + const resource = URI.parse(session); + this._sessionResidency.touch(resource); + if (!this._maybeScheduleEphemeralSessionGc(resource)) { + this._maybeScheduleSessionGc(resource); + } + })); core.callbackBinder.bind({ canEvictChangeset: changeset => this._canEvictChangeset(changeset), startAgentMergeTurn: (session, turnId, prompt) => this._startAgentMergePrompt(session, turnId, prompt), @@ -1286,7 +1296,23 @@ export class AgentService extends Disposable implements IAgentService { } private async _startSessionMessage(chat: URI, message: Message): Promise { - this._turnService.startTurnMessage(chat, message); + const channel = chat.toString(); + const preparation = this._prepareCanvasTurn(channel, parseRequiredSessionUriFromChatUri(channel), { + type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message, + }, undefined, createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)); + try { + await preparation?.run(message.text); + preparation?.commit(); + } finally { + preparation?.dispose(); + } + if (preparation && this._providerService.getProviderForSession(parseRequiredSessionUriFromChatUri(channel))?.canvases?.defersHostTurnStart) { + this._sideEffects.handleDeferredTurn(channel, { + type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message, + }, undefined, createUnknownAgentHostClientTelemetryContext(AgentHostClientType.Unknown)); + } else { + this._turnService.startTurnMessage(chat, message); + } } private async _cancelAutomationSession(session: URI): Promise { @@ -2891,6 +2917,24 @@ export class AgentService extends Disposable implements IAgentService { } async createSession(config?: IAgentCreateSessionConfig): Promise { + const provider = this._providerService.resolveProvider(config?.provider); + if (!provider?.canvases?.available) { + return this._createSession(config); + } + const session = config?.session ?? this._mintSessionUri(provider); + const chat = buildDefaultChatUri(session); + const initialization = !this._stateManager.getSessionState(session.toString()) && !this._canvases.getChatInitialization(chat) + ? this._canvases.beginChatCreation(chat) : undefined; + try { + const result = await this._createSession({ ...config, session }); + initialization?.commit(); + return result; + } finally { + initialization?.dispose(); + } + } + + private async _createSession(config?: IAgentCreateSessionConfig): Promise { const provider = this._providerService.resolveProvider(config?.provider); const isEphemeral = config ? readEphemeralSessionMeta(config).isEphemeral === true : false; if (!provider) { @@ -3063,6 +3107,15 @@ export class AgentService extends Disposable implements IAgentService { : Promise.resolve(undefined), ]); + try { + this._canvases.assertChatInitialization(defaultChat.toString()); + } catch (error) { + await this._rollbackProviderSession(provider, session); + this._stateManager.removeSession(session.toString()); + await this._sessionRegistry.tombstone(session); + throw error; + } + if (config?.importConversation) { // An imported conversation arrives with pre-existing turns (assigned // fresh UUID ids above). Seed them into the new session's protocol @@ -3194,6 +3247,23 @@ export class AgentService extends Disposable implements IAgentService { } async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { + if (parseChatUri(chat)?.session !== session.toString()) { + throw new Error('The new chat must belong to the exact creating session.'); + } + if (this._stateManager.getSessionState(session.toString())?.chats.some(entry => entry.resource === chat.toString())) { + return; + } + const initialization = !this._canvases.getChatInitialization(chat.toString()) && this._providerService.getProviderForSession(session)?.canvases?.available + ? this._canvases.beginChatCreation(chat.toString()) : undefined; + try { + await this._createAdditionalChat(session, chat, options); + initialization?.commit(); + } finally { + initialization?.dispose(); + } + } + + private async _createAdditionalChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { const sessionKey = session.toString(); const provider = this._providerService.getProviderForSession(session); if (!provider) { @@ -3292,12 +3362,15 @@ export class AgentService extends Disposable implements IAgentService { const createResult = await this._createChat(provider, chat, session, createOptions); const providerData = createResult?.providerData; try { + this._canvases.assertChatInitialization(chat.toString()); await this._persistPeerChat(session, chat, providerData, peerChatOrigin, createResult?.inheritedTurnId); + this._canvases.assertChatInitialization(chat.toString()); } catch (error) { try { await provider.chats.disposeChat(chat, this._chatContext(session, chat)); + await this._removePersistedPeerChat(session, chat); } catch (rollbackError) { - throw new AggregateError([error, rollbackError], `Failed to persist and roll back chat ${chat.toString()}`); + this._logService.error(`[AgentService] Failed to roll back chat ${chat.toString()}`, rollbackError); } throw error; } @@ -3393,6 +3466,7 @@ export class AgentService extends Disposable implements IAgentService { } async disposeChat(session: URI, chat: URI): Promise { + this._canvases.cancelChatInitialization(chat.toString()); const sessionKey = session.toString(); const chatKey = chat.toString(); const provider = this._providerService.getProviderForSession(session); @@ -3464,6 +3538,7 @@ export class AgentService extends Disposable implements IAgentService { ...(result?.provisional ? { provisional: true } : {}), ...(result ? { chat: result } : {}), }; + this._canvases.assertChatInitialization(defaultChatUri.toString()); if (deferWorktreeCreation && created.provisional) { this._worktree.notePending(AgentSession.id(created.session)); } @@ -4211,6 +4286,7 @@ export class AgentService extends Disposable implements IAgentService { } async disposeSession(session: URI): Promise { + this._canvases.cancelSessionInitialization(session.toString()); this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); await this._sessionResidency.runDisposal(session, () => this._doDisposeSession(session)); } @@ -4473,8 +4549,9 @@ export class AgentService extends Disposable implements IAgentService { addSubscriber(resource: URI, clientId: string): void { // A new subscriber means the session is being observed again; cancel // any pending GC armed while it had no subscribers. - this._cancelPendingSessionGc(resource); - this._cancelPendingEphemeralSessionGc(resource); + const owner = resolveAgentHostSession(resource, this._stateManager.getCanvasState(resource.toString())?.identity.chat); + this._cancelPendingSessionGc(owner); + this._cancelPendingEphemeralSessionGc(owner); // 0→1 transition — covers both the full subscribe path AND the // handshake fast-path used by `ProtocolServerHandler` when state is // already cached. The coordinator decides whether the URI is one @@ -4494,11 +4571,12 @@ export class AgentService extends Disposable implements IAgentService { } this._changesetCoordinator.onLastSubscriber(resource); this._stateManager.onChangesetLivenessChanged(); - if (this._maybeScheduleEphemeralSessionGc(resource)) { + const owner = resolveAgentHostSession(resource, this._stateManager.getCanvasState(resource.toString())?.identity.chat); + if (this._maybeScheduleEphemeralSessionGc(owner)) { return; } // Annotation subscribers block destructive GC, but must not suppress residency reconciliation. - this._maybeScheduleSessionGc(resource); + this._maybeScheduleSessionGc(owner); void this._sessionResidency.reconcile(); } @@ -4512,7 +4590,7 @@ export class AgentService extends Disposable implements IAgentService { if (!this._stateManager.isEphemeralSession(sessionKey)) { return false; } - if (this._subscriptions.hasSessionSubscribers(session)) { + if (this._subscriptions.hasSessionSubscribers(session) || this._canvases.holdsSession(sessionKey)) { return true; } this._pendingSessionGc.set(session, disposableTimeout(() => { @@ -4544,7 +4622,7 @@ export class AgentService extends Disposable implements IAgentService { */ private _maybeScheduleSessionGc(resource: URI): void { const session = resolveAgentHostSession(resource); - if (this._subscriptions.hasSessionSubscribers(session)) { + if (this._subscriptions.hasSessionSubscribers(session) || this._canvases.holdsSession(session.toString())) { return; } const key = session.toString(); @@ -4583,7 +4661,7 @@ export class AgentService extends Disposable implements IAgentService { } private async _runEphemeralSessionGc(session: URI): Promise { - if (this._subscriptions.hasSessionSubscribers(session)) { + if (this._subscriptions.hasSessionSubscribers(session) || this._canvases.holdsSession(session.toString())) { return; } this._logService.info(`[AgentService] GC: disposing unsubscribed ephemeral session ${session.toString()}`); @@ -4600,7 +4678,7 @@ export class AgentService extends Disposable implements IAgentService { */ private async _runSessionGc(resource: URI): Promise { const key = resource.toString(); - if (this._subscriptions.hasSessionSubscribers(resource)) { + if (this._subscriptions.hasSessionSubscribers(resource) || this._canvases.holdsSession(key)) { return; } const state = this._stateManager.getSessionState(key); @@ -4723,7 +4801,7 @@ export class AgentService extends Disposable implements IAgentService { return action.type === ActionType.AutomationRunCancelRequested; } - dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown): void { + dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction | ClientAutomationAction | ClientAutomationRunAction, clientId: string, clientSeq: number, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown, canvasInitiator?: IAgentCanvasApprovalClient): void { const clientContext = typeof clientContextOrType === 'string' ? createUnknownAgentHostClientTelemetryContext(clientContextOrType) : clientContextOrType; @@ -4783,9 +4861,26 @@ export class AgentService extends Disposable implements IAgentService { const requiresAttachmentRewrite = this._needsAsyncRewrite(sessionChannel, action); const requiresReviewStateUpdate = action.type === ActionType.ChangesetFilesReviewChanged; const requiresAnnotationsRestore = isAnnotationsAction(action); + if (action.type === ActionType.ChatTurnCancelled && this._canvases.cancelTurnPreparation(channel, action.turnId)) { + this._stateManager.dispatchClientAction(channel, action, { clientId, clientSeq }, clientContext); + return; + } + if (this._canvases.isChatInitializing(channel) && (action.type === ActionType.ChatToolCallConfirmed || action.type === ActionType.ChatInputCompleted || action.type === ActionType.ChatTurnCancelled)) { + this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq, clientContext); + return; + } const pending = this._clientDispatchQueues.get(clientId); - if (!pending && !requiresSessionRestore && !requiresPeerResolution && !requiresTurnOwnerResolution && !requiresAttachmentRewrite && !requiresReviewStateUpdate && !requiresAnnotationsRestore) { + let canvasPreparation: IAgentHostCanvasTurnPreparation | undefined; + try { + if (action.type === ActionType.ChatTurnStarted && !requiresSessionRestore && !requiresPeerResolution) { + canvasPreparation = this._prepareCanvasTurn(channel, sessionChannel, action, clientId, clientContext, canvasInitiator); + } + } catch (error) { + this._stateManager.rejectClientAction(channel, action, { clientId, clientSeq }, toErrorMessage(error)); + return; + } + if (!pending && !requiresSessionRestore && !requiresPeerResolution && !requiresTurnOwnerResolution && !requiresAttachmentRewrite && !requiresReviewStateUpdate && !requiresAnnotationsRestore && !canvasPreparation) { this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq, clientContext); return; } @@ -4830,6 +4925,12 @@ export class AgentService extends Disposable implements IAgentService { if (action.type === ActionType.ChatTurnStarted && requiresTurnOwnerResolution) { await this._resolvePeerChatsForTurnValidation(sessionChannel); } + if (action.type === ActionType.ChatTurnStarted) { + if (!canvasPreparation && (requiresSessionRestore || requiresPeerResolution)) { + canvasPreparation = this._prepareCanvasTurn(channel, sessionChannel, action, clientId, clientContext, canvasInitiator); + } + await canvasPreparation?.run(action.message.text); + } const rewritten: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction = requiresAttachmentRewrite ? await this._rewriteUserMessageAttachments(sessionChannel, action, clientId) : action; @@ -4841,11 +4942,16 @@ export class AgentService extends Disposable implements IAgentService { } this._changesets.refreshBranchChangeset(changeset.sessionUri); } - this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq, clientContext); + canvasPreparation?.commit(); + const deferTurnStart = canvasPreparation !== undefined && this._providerService.getProviderForSession(sessionChannel)?.canvases?.defersHostTurnStart === true; + this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq, clientContext, deferTurnStart); + canvasPreparation?.dispose(); + canvasPreparation = undefined; }).catch(err => { this._logService.error(`[AgentService] async dispatchAction failed: ${toErrorMessage(err)}`); this._stateManager.rejectClientAction(channel, action, { clientId, clientSeq }, toErrorMessage(err)); }).finally(() => { + canvasPreparation?.dispose(); if (this._clientDispatchQueues.get(clientId) === next) { this._clientDispatchQueues.delete(clientId); } @@ -4854,6 +4960,23 @@ export class AgentService extends Disposable implements IAgentService { this._clientDispatchQueues.set(clientId, next); } + private _prepareCanvasTurn(channel: string, session: string, action: ChatTurnStartedAction, clientId: string | undefined, clientContext: IAgentHostClientTelemetryContext, initiator?: IAgentCanvasApprovalClient): IAgentHostCanvasTurnPreparation | undefined { + if (!this._canvases.needsTurnInitialization(channel)) { + return undefined; + } + const disposition = this._chatContributions.incomingRequest({ + phase: 'preparation', session, chat: channel, turnChannel: channel, turnId: action.turnId, + message: action.message, source: 'direct', clientId, clientContext, + }); + if (disposition.kind === 'handled') { + return undefined; + } + if (disposition.kind === 'reject') { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, disposition.error.message, disposition.error); + } + return this._canvases.beginTurnPreparation(channel, action.turnId, clientId, initiator); + } + private _dispatchAutomationMigrationAction(channel: string, action: IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void { const pending = this._clientDispatchQueues.get(clientId); const next = (pending ?? Promise.resolve()).then(async () => { @@ -4939,13 +5062,17 @@ export class AgentService extends Disposable implements IAgentService { return preserved ? { ...action, config: { ...action.config, ...preserved } } : action; } - private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext): void { + private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number, clientContext: IAgentHostClientTelemetryContext, deferTurnStart = false): void { const origin = { clientId, clientSeq }; if (action.type === ActionType.SessionIsArchivedChanged && !action.isArchived && this._sessionResidency.isBeingDisposed(sessionChannel)) { this._stateManager.rejectClientAction(channel, action, origin, 'Cannot unarchive a session while it is being deleted.'); return; } if (action.type === ActionType.ChatTurnCancelled) { + if (this._stateManager.getDeferredTurnId(channel) === action.turnId) { + this._sideEffects.handleDeferredTurnCancellation(channel, action, origin, clientContext); + return; + } const resumedDuration = this._sideEffects.getResumedTurnDuration(channel, action.turnId); if (resumedDuration !== undefined) { action = { ...action, duration: resumedDuration }; @@ -5031,6 +5158,10 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.rejectClientAction(channel, action, origin, 'Invalid automation migration completion payload.'); return; } + if (deferTurnStart && action.type === ActionType.ChatTurnStarted) { + this._sideEffects.handleDeferredTurn(channel, action, origin, clientContext); + return; + } this._stateManager.dispatchClientAction(channel, action, origin, clientContext); if (action.type === ActionType.RootConfigChanged) { this._configurationService.persistRootConfig(); @@ -5869,7 +6000,7 @@ export class AgentService extends Disposable implements IAgentService { _meta: restoredMeta, }; - const { draft: defaultDraft, title: defaultChatTitle } = await this._chatContributions.hydrateChat({ + const { draft: defaultDraft, title: defaultChatTitle, canvases } = await this._chatContributions.hydrateChat({ session: sessionStr, chat: defaultChatUri.toString(), }, {}); @@ -5893,6 +6024,7 @@ export class AgentService extends Disposable implements IAgentService { } this._invalidateSessionList(); this._stateManager.restoreSession(summary, mergedTurns, { draft: restoredDraft, defaultChatTitle }); + this._stateManager.restoreCanvases(defaultChatUri.toString(), canvases); this._logService.trace(`[AgentService] restore: hydrated state for ${sessionStr} with ${mergedTurns.length} turn(s)`); this._serverToolHost.advertise(sessionStr); @@ -6051,17 +6183,17 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Skipping malformed persisted peer chat URI '${entry.uri}': ${toErrorMessage(err)}`); return undefined; } - const { title, draft } = await this._chatContributions.hydrateChat({ + const { title, draft, canvases } = await this._chatContributions.hydrateChat({ session: session.toString(), chat: chatUri.toString(), }, {}); - return { chatUri, title, draft, providerData: entry.providerData, origin: entry.origin, inheritedTurnId: entry.inheritedTurnId }; + return { chatUri, title, draft, canvases, providerData: entry.providerData, origin: entry.origin, inheritedTurnId: entry.inheritedTurnId }; })); for (const item of restored) { if (!item) { continue; } - const { chatUri, title, draft, providerData, origin, inheritedTurnId } = item; + const { chatUri, title, draft, canvases, providerData, origin, inheritedTurnId } = item; if (this._stateManager.getChatState(chatUri.toString())) { continue; } @@ -6073,6 +6205,7 @@ export class AgentService extends Disposable implements IAgentService { inheritedTurnId, resolver: currentProviderData => this._materializeRestoredPeerChat(session, chatUri, currentProviderData), }); + this._stateManager.restoreCanvases(chatUri.toString(), canvases); } } @@ -6400,7 +6533,7 @@ export class AgentService extends Disposable implements IAgentService { */ private _removePersistedPeerChat(session: URI, chat: URI): Promise { const chatUri = chat.toString(); - return this._enqueuePeerChatCatalogWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); + return this._enqueuePeerChatCatalogWrite(session, entries => entries.some(entry => entry.uri === chatUri) ? entries.filter(entry => entry.uri !== chatUri) : undefined); } /** @@ -6408,7 +6541,7 @@ export class AgentService extends Disposable implements IAgentService { * behind any in-flight write for the same session, so concurrent * create/dispose/data-change updates can't clobber each other. */ - private _enqueuePeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { + private _enqueuePeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[] | undefined): Promise { const key = session.toString(); const previous = this._peerChatCatalogWrites.get(key) ?? Promise.resolve(); const next = previous @@ -6427,7 +6560,7 @@ export class AgentService extends Disposable implements IAgentService { return tracked; } - private async _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise { + private async _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[] | undefined): Promise { const ref = this._sessionDataService.openDatabase(session); try { let current: IPersistedPeerChat[] = []; @@ -6450,7 +6583,9 @@ export class AgentService extends Disposable implements IAgentService { this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); } const updated = mutate(current); - await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); + if (updated) { + await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); + } } finally { ref.dispose(); } @@ -7401,7 +7536,7 @@ export class AgentService extends Disposable implements IAgentService { } const origin = { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: child.toolCallId } as const; const existing = this._stateManager.getSessionState(parentSessionStr)?.chats.find(chat => chat.resource === chatUri); - const { title: persistedTitle } = await this._chatContributions.hydrateChat({ + const { title: persistedTitle, canvases } = await this._chatContributions.hydrateChat({ session: parentSessionStr, chat: chatUri, }, {}); @@ -7414,6 +7549,7 @@ export class AgentService extends Disposable implements IAgentService { turns: [...await this._resolveRestoredSubagentTurns(agent, parentSession, chatUri, origin)], }), }); + this._stateManager.restoreCanvases(chatUri, canvases); if (existing && (!existing.title || existing.title === subagentChatTitle(undefined, undefined))) { this._stateManager.updateChatTitle(parentSessionStr, chatUri, title); } diff --git a/src/vs/platform/agentHost/node/agentSessionResidency.ts b/src/vs/platform/agentHost/node/agentSessionResidency.ts index 32fda069d5653..073c55f9def63 100644 --- a/src/vs/platform/agentHost/node/agentSessionResidency.ts +++ b/src/vs/platform/agentHost/node/agentSessionResidency.ts @@ -96,7 +96,7 @@ export class AgentSessionResidency extends Disposable { private _getResidencySession(resource: URI): URI | undefined { // Annotation ownership does not imply a dependency on the resident conversation; changesets still do. - return parseAnnotationsUri(resource.toString()) ? undefined : resolveAgentHostSession(resource); + return parseAnnotationsUri(resource.toString()) ? undefined : resolveAgentHostSession(resource, this._stateManager.getCanvasState(resource.toString())?.identity.chat); } private _hasResidencySubscribers(resource: URI): boolean { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 8365e08cb28a9..26f4c4c50513f 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -31,7 +31,7 @@ import { resolveChatAttachment } from '../common/state/chatAttachmentContext.js' import { buildOpenSessionLinkForChatResource } from '../common/openSessionLink.js'; import { ToolCallContributorKind, type AgentInfo, type SessionActiveClient } from '../common/state/protocol/state.js'; import type { CustomizationEnablement } from '../common/state/protocol/channels-session/state.js'; -import { ActionType, isChatAction, StateAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; +import { ActionType, isChatAction, StateAction, type ActionOrigin, type ChatTurnStartedAction, type ChatTurnCancelledAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; import { buildSubagentChatUri, createErrorResponsePart, @@ -52,6 +52,8 @@ import { SessionLifecycle, CustomizationType, ToolCallStatus, + ToolCallConfirmationReason, + ToolCallCancellationReason, ToolResultContentType, type ErrorInfo, type ISessionWithDefaultChat, @@ -78,6 +80,8 @@ import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetrySer import { getConfiguredSessionMode, getModelTelemetryContext, getTurnTelemetryContext } from './agentHostTurnTelemetryContext.js'; import { AgentHostTurnTracker, IAgentHostTurnTracker } from './agentHostTurnTracker.js'; import { IAgentHostTurnService } from './agentHostTurnService.js'; +import { IAgentHostCanvasesService } from './agentHostCanvasesService.js'; +import { localize } from '../../../nls.js'; import type { IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; import './localCommands/localChatCommands.contribution.js'; import { SessionPermissionManager } from './sessionPermissions.js'; @@ -240,6 +244,7 @@ export class AgentSideEffects extends Disposable { @IAgentHostToolCallTracker private readonly _toolCallTracker: AgentHostToolCallTracker, @IAgentHostWorktreeIsolation private readonly _worktree: IAgentHostWorktreeIsolation, @IAgentHostTurnService private readonly _turnService: IAgentHostTurnService, + @IAgentHostCanvasesService private readonly _canvases: IAgentHostCanvasesService, ) { super(); this.onDidStartTurn = this._turnTracker.onDidStartTurn; @@ -1306,7 +1311,11 @@ export class AgentSideEffects extends Disposable { const autoApproval = e.managedApprovalRequired || forbiddenSnapshotWrite ? undefined : await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); - const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); + if (turnId && this._stateManager.getActiveTurnId(sessionKey) !== turnId) { + agent.respondToPermissionRequest(e.state.toolCallId, false, e.chat); + return; + } + const part = this._stateManager.getChatState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; if (toolCall && toolCall.status !== ToolCallStatus.Streaming @@ -1328,7 +1337,7 @@ export class AgentSideEffects extends Disposable { this._logService.warn(`[AgentSideEffects] Denying write to read-only attachment snapshot: toolCallId=${e.state.toolCallId}`); this._toolCallAgents.delete(toolCallKey); this._managedApprovalToolCalls.delete(toolCallKey); - agent.respondToPermissionRequest(e.state.toolCallId, false); + agent.respondToPermissionRequest(e.state.toolCallId, false, e.chat); return; } if (e.managedApprovalRequired) { @@ -1344,7 +1353,7 @@ export class AgentSideEffects extends Disposable { effective = { ...e, state: { ...e.state, _meta: { ...toolCall?._meta, ...e.state._meta, ...toToolCallMeta({ autoApproveBySetting: true }) } } }; } else if (autoApproval !== undefined) { this._toolCallAgents.delete(toolCallKey); - agent.respondToPermissionRequest(e.state.toolCallId, true); + agent.respondToPermissionRequest(e.state.toolCallId, true, e.chat); // Strip confirmationTitle so createToolReadyAction emits the // auto-approved (no-options) action. effective = { ...e, state: { ...e.state, confirmationTitle: undefined } }; @@ -1366,6 +1375,24 @@ export class AgentSideEffects extends Disposable { // This action is synthesized here rather than routed through // `_dispatchActionForSession`, so feed the hang watchdog explicitly. this._turnTracker.markActivity(sessionKey, turnId, readyAction.type); + const initialization = this._canvases.getChatInitialization(sessionKey); + if (initialization && !this._stateManager.getSnapshot(sessionKey) && readyAction.confirmationTitle && !readyAction.confirmed + && readyAction.contributor?.kind !== ToolCallContributorKind.Client) { + const generation = this._stateManager.getChatGeneration(sessionKey); + const approved = await this._canvases.requestApproval(sessionKey, localize( + 'agentHost.initializingChatToolPermission', + "{0}\n\nThis tool is waiting while the chat initializes. Approval applies once to this call only.\n\n{1}", + typeof readyAction.confirmationTitle === 'string' ? readyAction.confirmationTitle : readyAction.confirmationTitle.markdown, getInlineToolInput(e.state.toolInput) ?? '', + ), initialization.token, initialization.clientId, initialization.initiator); + if (generation === this._stateManager.getChatGeneration(sessionKey) && this._stateManager.getActiveTurnId(sessionKey) === turnId) { + const confirmation = { + type: ActionType.ChatToolCallConfirmed, turnId, toolCallId: e.state.toolCallId, + ...(approved ? { approved: true, confirmed: ToolCallConfirmationReason.UserAction } as const : { approved: false, reason: ToolCallCancellationReason.Denied } as const), + } as const; + this._stateManager.dispatchServerAction(sessionKey, confirmation); + this.handleAction(sessionKey, confirmation, initialization.clientId); + } + } } handleAction(channel: ProtocolURI, action: StateAction, clientId?: string, clientContextOrType: IAgentHostClientTelemetryContext | AgentHostClientType = AgentHostClientType.Unknown, resumedTurn?: Turn, automaticArchive = false): void { @@ -1462,7 +1489,7 @@ export class AgentSideEffects extends Disposable { if (agentId) { this._toolCallAgents.delete(toolCallKey); const agent = this._options.agents.get().find(a => a.id === agentId); - agent?.respondToPermissionRequest(action.toolCallId, action.approved); + agent?.respondToPermissionRequest(action.toolCallId, action.approved, URI.parse(channel)); } else { this._logService.warn(`[AgentSideEffects] No agent for tool call confirmation: ${action.toolCallId}`); } @@ -1479,7 +1506,7 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatInputCompleted must be handled on an AHP chat channel: ${channel}`); } const agent = this._options.getAgent(sessionChannel); - agent?.respondToUserInputRequest(action.requestId, action.response, action.answers); + agent?.respondToUserInputRequest(action.requestId, action.response, action.answers, URI.parse(channel)); break; } case ActionType.ChatTurnCancelled: { @@ -1505,14 +1532,7 @@ export class AgentSideEffects extends Disposable { void this._checkpointService.discardTurnStartCheckpoint(URI.parse(sessionChannel), URI.parse(channel), action.turnId).catch(() => undefined); // Cancel all subagent sessions for this parent this.cancelSubagentSessions(channel); - const agent = this._options.getAgent(sessionChannel); - if (agent) { - const chat = URI.parse(channel); - const session = parseRequiredSessionUriFromChatUri(channel); - agent.chats.abort(chat, { ...this._chatContext(session, channel), clientTelemetryContext: clientContext }).catch(err => { - this._logService.error('[AgentSideEffects] abort failed', err); - }); - } + this._abortTurn(channel, action.turnId, clientContext); // Intentionally do NOT drain queued messages here: cancelling means // "stop", so messages queued behind the turn stay queued for the // user to dequeue/run manually. (A message the user sends *after* @@ -1647,6 +1667,43 @@ export class AgentSideEffects extends Disposable { this._chatContributions.didApplyClientAction({ channel, session: sessionChannel, action, clientId, clientContext }); } + handleDeferredTurn(channel: string, action: ChatTurnStartedAction, origin: ActionOrigin | undefined, clientContext: IAgentHostClientTelemetryContext): void { + this._stateManager.deferTurn(channel, action, origin, clientContext, () => { + if (origin) { + this._chatContributions.didApplyClientAction({ channel, session: parseRequiredSessionUriFromChatUri(channel), action, clientId: origin.clientId, clientContext }); + } + }); + this._turnService.handleTurnStarted(channel, action, origin?.clientId, clientContext); + } + + /** Cancels a pending host request without tearing down another native turn or its subagents. */ + handleDeferredTurnCancellation(channel: string, action: ChatTurnCancelledAction, origin: ActionOrigin, clientContext: IAgentHostClientTelemetryContext): void { + const session = parseRequiredSessionUriFromChatUri(channel); + const pending = this._stateManager.rejectDeferredTurn(channel, 'The requested turn was cancelled before its runtime boundary.'); + this._completeTurn(channel, action.turnId, 'cancelled'); + if (pending?.queuedMessageId) { + this._stateManager.dispatchServerAction(channel, { + type: ActionType.ChatPendingMessageRemoved, kind: PendingMessageKind.Queued, id: pending.queuedMessageId, + }); + } + this._stateManager.dispatchClientAction(channel, action, origin, clientContext); + void this._checkpointService.discardTurnStartCheckpoint(URI.parse(session), URI.parse(channel), action.turnId).catch(error => { + this._logService.warn('[AgentSideEffects] Failed to discard a cancelled pending turn checkpoint', error); + }); + this._abortTurn(channel, action.turnId, clientContext); + this._chatContributions.didApplyClientAction({ channel, session, action, clientId: origin.clientId, clientContext }); + } + + private _abortTurn(channel: string, turnId: string, clientContext: IAgentHostClientTelemetryContext): void { + const session = parseRequiredSessionUriFromChatUri(channel); + const agent = this._options.getAgent(session); + if (agent) { + void agent.chats.abort(URI.parse(channel), { ...this._chatContext(session, channel), clientTelemetryContext: clientContext }, turnId).catch(error => { + this._logService.error('[AgentSideEffects] abort failed', error); + }); + } + } + private _recordCustomizationEnablement(session: ProtocolURI, candidate: ICustomizationEnablementCandidate, enablement: readonly CustomizationEnablement[]): void { const target = candidate.customization.type === CustomizationType.Plugin ? targetForPlugin(candidate.customization) diff --git a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts index b9d740c87452a..f9daac5f434ba 100644 --- a/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts +++ b/src/vs/platform/agentHost/node/chatContributions/builtInChatContributions.ts @@ -7,6 +7,7 @@ import { DisposableStore, type IDisposable } from '../../../../base/common/lifec import { IAgentHostChatContributions } from '../../common/agentHostChatContributionsService.js'; import { ArtifactToolsContribution } from './artifactTools/artifactToolsContribution.js'; import { ChatDraftContribution } from './chatDraft/chatDraftContribution.js'; +import { CanvasesContribution } from './canvases/canvasesContribution.js'; import { ChatSurfaceContribution } from './chatSurface/chatSurfaceContribution.js'; import { CheckpointAndChangesetContribution } from './checkpointAndChangeset/checkpointAndChangesetContribution.js'; import { GitHubReferencesContribution } from './githubReferences/githubReferencesContribution.js'; @@ -44,6 +45,7 @@ export function registerBuiltInChatContributions( registrations.add(contributions.registerContribution(SessionTitleContribution)); registrations.add(contributions.registerContribution(MarkUnreadContribution)); registrations.add(contributions.registerContribution(ChatDraftContribution)); + registrations.add(contributions.registerContribution(CanvasesContribution)); registrations.add(contributions.registerContribution(MarkdownPlanRichLinksContribution)); registrations.add(contributions.registerContribution(ArtifactToolsContribution)); registrations.add(contributions.registerContribution(ChatSurfaceContribution)); diff --git a/src/vs/platform/agentHost/node/chatContributions/canvases/canvasesContribution.ts b/src/vs/platform/agentHost/node/chatContributions/canvases/canvasesContribution.ts new file mode 100644 index 0000000000000..f52b21a9ad5fd --- /dev/null +++ b/src/vs/platform/agentHost/node/chatContributions/canvases/canvasesContribution.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { ILogService } from '../../../../log/common/log.js'; +import type { IAgentHostChatContribution, IAgentHostChatContributionContext, IDispatchedAction, IHydrationContext, IRestoredChat, IIncomingRequest, IncomingRequestDisposition } from '../../../common/agentHostChatContributionsService.js'; +import { CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN } from '../../../common/agentHostCanvases.js'; +import { ISessionDataService } from '../../../common/sessionDataService.js'; +import { chatStorageUri, MessageKind, type Turn } from '../../../common/state/sessionState.js'; +import { ActionType } from '../../../common/state/sessionActions.js'; +import { IAgentHostCanvasesService } from '../../agentHostCanvasesService.js'; +import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostStateManager.js'; + +/** Restores durable membership before catalog publication, without touching executable providers. */ +export class CanvasesContribution extends Disposable implements IAgentHostChatContribution { + static readonly id = 'canvases'; + readonly order = 650; + + constructor( + protected readonly _context: IAgentHostChatContributionContext, + @IAgentHostCanvasesService private readonly _canvases: IAgentHostCanvasesService, + @ILogService private readonly _logService: ILogService, + @ISessionDataService private readonly _sessionData: ISessionDataService, + @IAgentHostStateManager private readonly _state: AgentHostStateManager, + ) { + super(); + } + + onIncomingRequest(request: IIncomingRequest): IncomingRequestDisposition | undefined { + return this._canvases.isChatInitializing(request.chat) || request.phase === 'preparation' && this._state.getDeferredTurnId(request.chat) ? { + kind: 'reject', + stage: 'validation', + error: { errorType: 'canvasInitializationPending', message: localize('canvasInitializationPending', "This chat is initializing its canvas runtime. Wait for initialization before starting a turn.") }, + } : undefined; + } + + async onHydrateChat(context: IHydrationContext, restored: IRestoredChat): Promise { + const canvases = await this._canvases.loadChat(context.chat); + return canvases.length ? { ...restored, canvases } : restored; + } + + async onHydrateTurns(context: IHydrationContext, turns: readonly Turn[]): Promise { + const storage = turns.length ? chatStorageUri(URI.parse(context.chat)) : undefined; + const reference = storage ? await this._sessionData.tryOpenDatabase(storage) : undefined; + if (!reference) { + return turns; + } + try { + const origins = await reference.object.getTurnMessageOrigins(); + return origins.size ? turns.map(turn => origins.get(turn.id) === CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN ? { + ...turn, + message: { + ...turn.message, origin: { kind: MessageKind.Tool }, + _meta: { ...turn.message._meta, copilotOrigin: CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN }, + }, + } : turn) : turns; + } finally { + reference.dispose(); + } + } + + onDidDispatchAction(observed: IDispatchedAction): void { + if (!observed.rejectionReason && observed.action.type === ActionType.SessionChatRemoved) { + this._canvases.cancelChatInitialization(observed.action.chat); + void this._canvases.persistChat(observed.action.chat).catch(() => this._logService.warn('[Canvases] Failed to persist removed chat membership.')); + } + } +} diff --git a/src/vs/platform/agentHost/node/chatContributions/localCommand/localCommandContribution.ts b/src/vs/platform/agentHost/node/chatContributions/localCommand/localCommandContribution.ts index 62e2525d344e0..996f1ca40287e 100644 --- a/src/vs/platform/agentHost/node/chatContributions/localCommand/localCommandContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/localCommand/localCommandContribution.ts @@ -26,6 +26,9 @@ export class LocalCommandContribution extends Disposable implements IAgentHostCh } onIncomingRequest(request: IIncomingRequest): IncomingRequestDisposition | undefined { + if (request.phase === 'preparation') { + return this._localCommands.canHandle({ turnChannel: request.turnChannel, turnId: request.turnId, text: request.message.text }) ? { kind: 'handled' } : undefined; + } const handled = this._localCommands.tryHandle({ turnChannel: request.turnChannel, turnId: request.turnId, diff --git a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts index 769cc4bec90ee..8e9460c0abb9c 100644 --- a/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts +++ b/src/vs/platform/agentHost/node/chatContributions/queueDrain/queueDrainContribution.ts @@ -18,8 +18,10 @@ import { AgentHostStateManager, IAgentHostStateManager } from '../../agentHostSt import { IAgentHostProviderService } from '../../agentHostProviderService.js'; import { startTurn } from '../../agentHostTurnStarter.js'; import { ISessionWorkspaceConversionService } from '../sessionWorkspaceConversion/sessionWorkspaceConversionService.js'; +import { IAgentHostCanvasesService } from '../../agentHostCanvasesService.js'; const QueuedSender = createChatMementoKey('queueDrain.sender', () => undefined); +const InitializationFailed = createChatMementoKey('queueDrain.initializationFailed', () => false); /** Owns queued-message sender state and decides when a queued turn can be admitted. */ export class QueueDrainContribution extends Disposable implements IAgentHostChatContribution { @@ -35,8 +37,14 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat @IAgentHostProviderService private readonly _providerService: IAgentHostProviderService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @ISessionWorkspaceConversionService private readonly _conversionService: ISessionWorkspaceConversionService, + @IAgentHostCanvasesService private readonly _canvases: IAgentHostCanvasesService, ) { super(); + this._register(this._canvases.onDidReleaseHold(session => { + for (const chat of this._stateManager.getSessionState(session)?.chats ?? []) { + this._tryConsumeNextQueuedMessage(chat.resource); + } + })); } onTurnEnd(turn: ITurnEnd): void { @@ -77,6 +85,7 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat } private _syncPendingMessages(channel: ProtocolURI): void { + this._context.memento(InitializationFailed, channel).set(false, undefined); const state = this._stateManager.getSessionState(channel); if (!state) { return; @@ -91,7 +100,8 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat } private _tryConsumeNextQueuedMessage(channel: ProtocolURI): void { - if (this._conversionService.isPending(channel)) { + if (this._conversionService.isPending(channel) || this._canvases.isChatInitializing(channel) || this._stateManager.getDeferredTurnId(channel) + || this._context.memento(InitializationFailed, channel).get()) { return; } if (this._stateManager.getActiveTurnId(channel)) { @@ -119,20 +129,49 @@ export class QueueDrainContribution extends Disposable implements IAgentHostChat }; // Drop the entry rather than blanking it: the memento is keyed by message // id, so a long-lived chat would otherwise retain one per message queued. - this._context.deleteMemento(QueuedSender, channel, message.id); this._admitQueuedTurn(host, channel, message.message, message.id, sender); } - private _admitQueuedTurn(host: IAgentHostChatContributionHost, channel: ProtocolURI, message: Message, messageId: string, sender: IQueuedMessageSender): void { + private _admitQueuedTurn(host: IAgentHostChatContributionHost, channel: ProtocolURI, message: Message, messageId: string, sender: IQueuedMessageSender, turnId = generateUuid(), prepared = false): void { const sessionChannel = parseRequiredSessionUriFromChatUri(channel); - const turnId = generateUuid(); - this._stateManager.dispatchServerAction(channel, { + if (!prepared && this._canvases.needsTurnInitialization(channel)) { + const disposition = this._chatContributions.incomingRequest({ + phase: 'preparation', session: sessionChannel, chat: channel, turnChannel: channel, turnId, message, + source: 'queued', clientId: sender.clientId, clientContext: sender.clientContext, + }); + if (disposition.kind === 'reject') { + return; + } + if (disposition.kind === 'accept') { + const preparation = this._canvases.beginTurnPreparation(channel, turnId, sender.clientId); + const generation = this._stateManager.getChatGeneration(channel); + void preparation.run(message.text).then(() => { + preparation.commit(); + if (this._stateManager.getChatState(channel)?.queuedMessages?.some(queued => queued.id === messageId)) { + this._admitQueuedTurn(host, channel, message, messageId, sender, turnId, true); + } + }).catch(error => { + if (generation === this._stateManager.getChatGeneration(channel)) { + this._context.memento(InitializationFailed, channel).set(true, undefined); + } + this._logService.warn('[QueueDrainContribution] Canvas initialization failed; the queued message was not sent', error); + }).finally(() => preparation.dispose()); + return; + } + } + this._context.deleteMemento(QueuedSender, channel, messageId); + const action = { type: ActionType.ChatTurnStarted, turnId, startedAt: new Date().toISOString(), message, queuedMessageId: messageId, - }); + } as const; + if (prepared && this._providerService.getProviderForSession(sessionChannel)?.canvases?.defersHostTurnStart) { + this._stateManager.deferTurn(channel, action, undefined, sender.clientContext, () => { }); + } else { + this._stateManager.dispatchServerAction(channel, action); + } const turnStopWatch = StopWatch.create(false); const started = this._instantiationService.invokeFunction(startTurn, { session: sessionChannel, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 9861eed999c82..5b178df6b66d2 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -48,6 +48,8 @@ import { AgentChatOperationContext, AgentSession, AgentSignal, AuthenticateParam import { getReasoningEffortDescription, getReasoningEffortLabel, resolveDefaultReasoningEffort } from '../../common/reasoningEffort.js'; import { autoModeTiers, defaultAutoModeTier, getAutoModeTierDescription, getAutoModeTierLabel } from '../../common/autoModeTiers.js'; import { isAutoModel } from './modelIdentifiers.js'; +import { CopilotCanvases } from './copilotCanvases.js'; +import type { IAgentCanvasOperation } from '../../common/agentHostCanvases.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; @@ -61,7 +63,7 @@ import type { ErrorInfo } from '../../common/state/protocol/common/state.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js'; import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_EHCLI_ADOPTED_DB_KEY, AH_META_EHCLI_LAST_TURN_DB_KEY, AH_META_IS_READ_DB_KEY, isDefaultChatUri, parseChatUri, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type ISessionFolderPickerDecision, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { getByokLmAgentModelId, resolveByokLmEnablement } from '../../common/agentHostByokLm.js'; import { isCustomizationEnabled } from '../../common/customizationEnablement.js'; import { ActiveClientToolSet, structuralToolsEqual } from '../activeClientState.js'; @@ -926,6 +928,8 @@ export class CopilotAgent extends Disposable implements IAgent { private _isShuttingDown = false; private readonly _plugins: PluginController; private readonly _sessionLauncher: CopilotSessionLauncher; + readonly canvases: CopilotCanvases | undefined; + private readonly _canvasRuntimePath: string | undefined; private readonly _gitHubTelemetryForwarder: CopilotGitHubTelemetryForwarder; private readonly _secondaryAssignmentContext: CopilotSecondaryAssignmentContext; private readonly _githubTelemetryRouter: AgentHostGitHubTelemetryRouter | undefined; @@ -967,8 +971,24 @@ export class CopilotAgent extends Disposable implements IAgent { this._register(this._githubCredentials.onDidRequestRefresh(() => this._handleCopilotSessionAuthRequired())); this._worktree = worktree; this._lastStartupConfig = this._readClientStartupConfig(); + const canvasRuntimePath = process.env['VSCODE_AGENT_HOST_CANVAS_RUNTIME_PATH']; + this._canvasRuntimePath = !this._environmentService.isBuilt && canvasRuntimePath && isAbsolute(canvasRuntimePath) ? canvasRuntimePath : undefined; + if (this._canvasRuntimePath) { + this.canvases = this._register(this._instantiationService.createInstance(CopilotCanvases, { + prepare: (chat, operation) => this._prepareCanvasChat(chat, operation), + isBusy: chat => this._findRoutableChat(URI.parse(chat))?.hasActiveTurn === true, + residentChats: () => this._allRoutableSessions().map(session => session.chatUri.toString()), + recoverOwnedRuntime: async () => { + if (this._chatsWithActiveTurn() > 0) { + throw new Error('The owned canvas runtime cannot restart while a chat turn is active.'); + } + await this._requestClientRestart('explicit canvas owner recovery'); + await this._ensureClient(); + }, + })); + } this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient())); - this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); + this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher, this.canvases); this._configurationService.publishRootTransientValues?.({ [CopilotCliVSCodeAssignmentContextKey]: undefined }); this._gitHubTelemetryForwarder = this._instantiationService.createInstance(CopilotGitHubTelemetryForwarder, () => this._restrictedTelemetryEnabled); this._secondaryAssignmentContext = this._instantiationService.createInstance(CopilotSecondaryAssignmentContext); @@ -1044,6 +1064,13 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.error('[Copilot] Failed to restart client after endpoint change', err) ); })); + if (this.canvases) { + // Explicit dev-runtime selection starts only the owned client handshake here. + // No session or extension is created by this readiness probe or by catalogue reads. + this.canvases.trackStartup(this._ensureClient().then(() => undefined, () => { + this._logService.warn('[Copilot] The selected preview runtime did not negotiate canvas launch ownership.'); + })); + } } /** @@ -1240,6 +1267,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (!failureKind) { return undefined; } + if (this.canvases && failureKind === 'connectionClosed') { + this.canvases.loseAuthority(); + return undefined; + } const clientFailureId = this._closedConnectionRecovery?.clientFailureId ?? generateUuid(); const recoveryStarted = failureKind === 'connectionClosed' && !this._shutdownPromise && this._closedConnectionRecovery === undefined; @@ -1328,7 +1359,7 @@ export class CopilotAgent extends Disposable implements IAgent { /** Number of live chats (default or peer, across all sessions) with an in-flight turn. */ private _chatsWithActiveTurn(): number { - return this._allLiveSessions().filter(session => session.hasActiveTurn).length; + return this._allRoutableSessions().filter(session => session.hasActiveTurn).length; } protected _createCopilotClient(options: CopilotClientOptions): CopilotClient { @@ -1821,8 +1852,7 @@ export class CopilotAgent extends Disposable implements IAgent { async handleAuthenticationToken(params: AuthenticateParams): Promise { let handled = false; - const sessions = new Set([...this._sessionsPendingRegistration.values(), ...this._allLiveSessions()]); - for (const session of sessions) { + for (const session of this._allRoutableSessions()) { const didHandle = await session.resolveMcpAuthentication(params); handled ||= didHandle; } @@ -2157,6 +2187,7 @@ export class CopilotAgent extends Disposable implements IAgent { } private _stopClient(): Promise { + this.canvases?.clientStopped(); // Any parked restart is satisfied by this stop: the next `_ensureClient` // starts from the current config, so nothing is left to re-apply. Cleared // synchronously so a concurrent `_applyPendingClientRestart` bails rather @@ -2195,6 +2226,7 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- client lifecycle --------------------------------------------------- private async _stopClientAfterStartupTermination(client: CopilotClient, terminalError: Error): Promise { + this.canvases?.clientStopped(client); try { await client.stop(); } catch (error) { @@ -2254,6 +2286,9 @@ export class CopilotAgent extends Disposable implements IAgent { } private async _ensureClientOnce(): Promise { + if (this.canvases?.authorityLost) { + throw new Error('The canvas launch-provider authority was lost. Explicit owned-runtime recovery is required.'); + } if (this._shutdownPromise) { throw new CancellationError(); } @@ -2339,7 +2374,7 @@ export class CopilotAgent extends Disposable implements IAgent { // We can't use require.resolve() because @github/copilot's exports map // blocks direct subpath access. const nodeModulesUri = getAppNodeModulesUri(); - const cliPath = await resolveCopilotCliPath(nodeModulesUri); + const cliPath = this._canvasRuntimePath ?? await resolveCopilotCliPath(nodeModulesUri); // The SDK's sandbox auto-detection looks for `//wxc-exec.exe` // (and the Linux/macOS equivalents). VS Code core ships the MXC sandbox binaries @@ -2389,9 +2424,12 @@ export class CopilotAgent extends Disposable implements IAgent { enableRemoteSessions: startupConfig.sessionSync, onGetTraceContext: () => this._otelService.getCurrentTraceContext() ?? {}, onGitHubTelemetry: notification => { void this._routeGitHubTelemetry(notification).catch(err => this._logService.trace(`[Copilot] GitHub telemetry routing failed: ${err instanceof Error ? err.message : String(err)}`)); }, + ...(this.canvases ? { extensionLaunchProvider: this.canvases.launchProvider } : {}), }; const client = this._createCopilotClient(clientOptions); + this.canvases?.clientStarting(client); await client.start(); + this.canvases?.clientStarted(client); if (this._shutdownPromise) { return this._stopClientAfterStartupTermination(client, new CancellationError()); } @@ -3240,8 +3278,8 @@ export class CopilotAgent extends Disposable implements IAgent { const clientTelemetryContext = URI.isUri(operationContext) ? undefined : operationContext?.clientTelemetryContext; return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId, clientType, workingDirectories, operationContext, clientTelemetryContext); }, - abort: (chatUri: URI, context: URI | IAgentChatContext): Promise => { - return this._abortSession(chatUri, context); + abort: (chatUri: URI, context: URI | IAgentChatContext, turnId?: string): Promise => { + return this._abortSession(chatUri, context, turnId); }, getModel: (chatUri: URI): ModelSelection | undefined => this._chatBackings.get(chatUri.toString())?.model, changeModel: (chatUri: URI, model: ModelSelection, context: URI | IAgentChatContext): Promise => { @@ -4141,8 +4179,8 @@ export class CopilotAgent extends Disposable implements IAgent { /** Routes a completed client tool call to the runtime that owns it. */ onClientToolCallComplete(chat: URI, toolCallId: string, result: ToolCallResult, context?: IAgentChatContext): void { const spawnedFrom = resolveSubagentChatParent(context); - const target = this._findChatByUri(chat) - ?? (spawnedFrom ? this._findChatByUri(spawnedFrom.chat) : undefined) + const target = this._findRoutableChat(chat) + ?? (spawnedFrom ? this._findRoutableChat(spawnedFrom.chat) : undefined) ?? (context ? this._findSessionChat(context.configurationResource) : undefined); target?.handleClientToolCallComplete(toolCallId, result); } @@ -4365,9 +4403,9 @@ export class CopilotAgent extends Disposable implements IAgent { await this._applyPendingClientRestart(); } - private async _abortSession(chat: URI, operationContext: URI | IAgentChatContext): Promise { + private async _abortSession(chat: URI, operationContext: URI | IAgentChatContext, turnId?: string): Promise { const context = this._resolveChatContext(chat, operationContext); - const abort = this._abortSessionWithRecovery(chat, operationContext); + const abort = this._abortSessionWithRecovery(chat, operationContext, turnId); const barrier = abort.then(() => undefined, () => undefined); const queuedBarrier = this._queueChat(context.configurationId, context.sequencerKey, 'abortBarrier', () => barrier); void queuedBarrier.catch(error => { @@ -4378,9 +4416,9 @@ export class CopilotAgent extends Disposable implements IAgent { await abort; } - private async _abortSessionWithRecovery(chat: URI, operationContext: URI | IAgentChatContext): Promise { + private async _abortSessionWithRecovery(chat: URI, operationContext: URI | IAgentChatContext, turnId?: string): Promise { try { - await this._abortSessionOnce(chat, operationContext); + await this._abortSessionOnce(chat, operationContext, turnId); } catch (error) { const correlation = this._clientFailureCorrelation(chat, undefined, operationContext); if (!isCopilotConnectionClosedError(error)) { @@ -4394,7 +4432,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _abortSessionOnce(chat: URI, operationContext: URI | IAgentChatContext): Promise { + private async _abortSessionOnce(chat: URI, operationContext: URI | IAgentChatContext, turnId?: string): Promise { const context = this._resolveChatContext(chat, operationContext); if (!context.target) { // No live session to abort. If work is still queued for this chat it @@ -4406,7 +4444,7 @@ export class CopilotAgent extends Disposable implements IAgent { } return; } - await context.target.abort(); + await context.target.abort(turnId); } /** Creates a concrete chat backing immediately, optionally by importing history from another chat. */ @@ -4617,6 +4655,33 @@ export class CopilotAgent extends Disposable implements IAgent { return context.target; } + private async _prepareCanvasChat(chat: string, operation: IAgentCanvasOperation): Promise { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error('Canvas initialization requires an exact registered chat.'); + } + const resource = URI.parse(chat); + const context = this._resolveChatContext(resource, { configurationResource: URI.parse(parsed.session), resource: this._resolveChatStorageScope(resource) }); + if (!context.target && this._provisionalSessions.has(context.configurationId) + && this._configurationService.getSessionConfigValues(parsed.session)?.[SessionConfigKey.Isolation] === 'worktree' && !operation.workingDirectories?.length) { + throw new Error(localize('copilot.canvasWorktreePreparation', "Canvas execution requires host-owned worktree preparation. It cannot initialize in the picked repository before isolation is ready.")); + } + await this._queueChat(context.configurationId, context.sequencerKey, 'prepareCanvas', async () => { + if (operation.token.isCancellationRequested) { + throw new CancellationError(); + } + operation.willExecute(); + const session = await this._ensureResolvedChatSession(context, operation.workingDirectories); + if (operation.token.isCancellationRequested) { + session?.dispose(); + throw new CancellationError(); + } + if (!session || session.chatUri.toString() !== chat) { + throw new Error('Canvas initialization could not acquire its exact backing chat.'); + } + }); + } + /** * Forks {@link sourceEntry}'s SDK chat at {@link turnId} via the * SDK `sessions.fork` RPC and copies its database into {@link targetDbDir} @@ -5139,16 +5204,24 @@ export class CopilotAgent extends Disposable implements IAgent { return this._shutdownPromise; } - respondToPermissionRequest(requestId: string, approved: boolean): void { - for (const chat of this._allLiveSessions()) { + respondToPermissionRequest(requestId: string, approved: boolean, chat?: URI): void { + if (chat) { + this._findRoutableChat(chat)?.respondToPermissionRequest(requestId, approved); + return; + } + for (const chat of this._allRoutableSessions()) { if (chat.respondToPermissionRequest(requestId, approved)) { return; } } } - respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record): void { - for (const chat of this._allLiveSessions()) { + respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record, chat?: URI): void { + if (chat) { + this._findRoutableChat(chat)?.respondToUserInputRequest(requestId, response, answers); + return; + } + for (const chat of this._allRoutableSessions()) { if (chat.respondToUserInputRequest(requestId, response, answers)) { return; } @@ -5417,6 +5490,15 @@ export class CopilotAgent extends Disposable implements IAgent { return [...this._chatEntriesBySdkId.values()].map(entry => entry.chatSession); } + /** Creation can await callbacks from top-level extensions before the SDK session is registered. */ + private _allRoutableSessions(): CopilotAgentSession[] { + return [...new Set([...this._sessionsPendingRegistration.values(), ...this._allLiveSessions()])]; + } + + private _findRoutableChat(chat: URI): CopilotAgentSession | undefined { + return this._findChatByUri(chat) ?? [...this._sessionsPendingRegistration.values()].find(session => isEqual(session.chatChannelUri, chat)); + } + /** Keeps SDK callbacks routable until ownership transfers to the live-session map. */ private async _initializeAndRegisterSession(session: CopilotAgentSession, register: () => void, beforeRegistration?: () => void | Promise): Promise { if (this._isShuttingDown) { diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 0bf059008eb56..e211a77b316cd 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotSession, CurrentToolMetadata, ElicitationContext, ElicitationFieldValue, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExitPlanModeCompletedData, ExitPlanModeRequest, ExitPlanModeResult, JsonValue, McpServersLoadedServer, MessageOptions, PermissionMode, PermissionAssistedApproval, PermissionRequest, PermissionRequestResult, PermissionResult, SessionConfig, SessionHooks, SessionMode as CopilotSdkMode, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; +import type { Attachment, CopilotSession, CurrentToolMetadata, ElicitationContext, ElicitationFieldValue, ElicitationResult, ElicitationSchema, ElicitationSchemaField, ExitPlanModeCompletedData, ExitPlanModeRequest, ExitPlanModeResult, JsonValue, McpServersLoadedServer, PermissionMode, PermissionAssistedApproval, PermissionRequest, PermissionRequestResult, PermissionResult, SessionConfig, SessionHooks, SessionMode as CopilotSdkMode, Tool, ToolResultObject, McpServerStatus as SdkMcpServerStatus } from '@github/copilot-sdk'; import { realpath as fsRealpath } from 'fs'; import { cp, rm } from 'fs/promises'; import { promisify } from 'util'; @@ -13,7 +13,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { Emitter } from '../../../../base/common/event.js'; import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js'; import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, IReference, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../base/common/lifecycle.js'; import { LRUCache } from '../../../../base/common/map.js'; import { Schemas } from '../../../../base/common/network.js'; import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; @@ -85,8 +85,9 @@ import type { IAgentHostRestrictedTelemetryContext } from '../agentHostRestricte import { buildChatErrorInfoFromCopilotSdkFields } from './copilotSdkChatError.js'; import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; import { getSdkMcpServerEnablement, resolveCustomizationEnablement, targetForMcpServer } from '../shared/customizationEnablementGate.js'; -import { appendSdkToolResultContent, mapSessionEvents } from './mapSessionEvents.js'; -import { addAttachmentDisplayKindToMimeType, addSimpleAttachmentDisplayKindToMimeType } from './copilotAttachmentUtils.js'; +import { appendSdkToolResultContent, mapSessionEvents, sdkAttachmentsToProtocol } from './mapSessionEvents.js'; +import { CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN } from '../../common/agentHostCanvases.js'; +import { addAttachmentDisplayKindToMimeType, addSimpleAttachmentDisplayKindToMimeType, readExtensionContext } from './copilotAttachmentUtils.js'; import { buildPendingEditContentUri } from './pendingEditContentStore.js'; import { IAgentHostCustomizationEnablementService } from '../agentHostCustomizationEnablementService.js'; import { IAgentHostPromptCache } from '../agentHostPromptCache.js'; @@ -98,7 +99,7 @@ import { createCopilotFailureCorrelation, reportCopilotModelCallFailure, reportC import { reportCopilotTodoStoreOperation } from './copilotTodoStoreTelemetry.js'; import { ModelCallTurnCorrelation } from './modelCallTurnCorrelation.js'; -type CopilotSdkAttachment = Required['attachments'][number]; +type CopilotSdkAttachment = Extract | (Extract & { data: string }); type CopilotCommandInvocationResult = Awaited>; type RuntimeSlashCommandInfo = Awaited>['commands'][number]; type GitHubCredentialsUpdateResult = Awaited>; @@ -724,6 +725,10 @@ class CopilotTurn extends Disposable { return this._eventId.p; } + get hasEventId(): boolean { + return this._eventId.isSettled; + } + constructor( readonly id: string, readonly ordinal: number, @@ -1059,6 +1064,9 @@ export class CopilotAgentSession extends Disposable { private readonly _sessionUsageMetricsRefreshThrottler = this._register(new Throttler()); /** SDK session wrapper, set by {@link initializeSession}. */ private _wrapper!: CopilotSessionWrapper; + private readonly _observedNativeMessages = new LRUCache(2048); + private readonly _hostMessageIds = new LRUCache(2048); + private readonly _steeringMessageIds = new LRUCache(2048); private _workingDirectoryMutationInProgress = false; private _requiresRestartAfterWorkingDirectoryChange = false; private readonly _slashCommandProvider: CopilotSlashCommandProvider; @@ -1410,14 +1418,22 @@ export class CopilotAgentSession extends Disposable { * history.truncate / sessions.fork mapping. */ private _beginSteeringTurn(steering: PendingMessage): string { - this._completeActiveTurn(); - const newTurnId = generateUuid(); + return this._beginNativeTurn(steering.message, steering.id); + } + + private _beginNativeTurn(message: Message, queuedMessageId?: string, eventId?: string): string { + if (eventId && this._currentTurn.value && !this._currentTurn.value.hasEventId) { + this._clearActiveTurn(); + } else { + this._completeActiveTurn(); + } + const newTurnId = eventId ?? generateUuid(); this._emitAction({ type: ActionType.ChatTurnStarted, turnId: newTurnId, startedAt: new Date().toISOString(), - message: steering.message, - queuedMessageId: steering.id, + message, + queuedMessageId, }); // Mirror `resetTurnState` so per-turn counters/mappings (usage total, // streaming part ids) don't bleed from the preempted turn into the new @@ -1429,7 +1445,7 @@ export class CopilotAgentSession extends Disposable { this.resetTurnState(newTurnId); const turn = this._currentTurn.value; if (turn) { - turn.messageCharLen = steering.message.text.length; + turn.messageCharLen = message.text.length; turn.markRunning(); } if (this._activeRootSdkTurnId) { @@ -2370,7 +2386,11 @@ export class CopilotAgentSession extends Disposable { this._logService.error(error, `[Copilot:${this.sessionId}] Failed to release sampling event interest`); }); })); - this._wrapper = this._register(wrapper); + const eventHandlersInstalled = this._wrapper === wrapper; + this._installSessionEventHandlers(wrapper); + if (eventHandlersInstalled) { + this._seedMcpServersFromRpc(); + } this._register(this._customizationEnablementService.onDidChange(event => { if (!event.sessions.includes(this._ownerSessionUri.toString())) { return; @@ -2378,11 +2398,8 @@ export class CopilotAgentSession extends Disposable { this._markMcpLaunchConfigurationDirty(); this._reconcileMcpServerEnablement().catch(error => this._logService.error(error, `[Copilot:${this.sessionId}] Failed to reconcile MCP enablement after customizations changed`)); })); - this._subscribeToEvents(); - this._subscribeToSdkEvents(); - this._subscribeForMemoInvalidation(); - this._subscribeForInstructionsCollectedTelemetry(); this._subscribeToPermissionConfigChanges(); + wrapper.releaseBufferedEvents(); await this._syncShellInitScript(); this._promptCacheState = this._promptCache.read(this.resourceUri); if (this._launchPlan.kind === 'resume') { @@ -2414,8 +2431,24 @@ export class CopilotAgentSession extends Disposable { this._promptCacheState = this._promptCache.write(this.resourceUri, promptCache); } + private _installSessionEventHandlers(wrapper: CopilotSessionWrapper): void { + if (this._wrapper === wrapper) { + return; + } + if (this._store.isDisposed) { + wrapper.dispose(); + throw new CancellationError(); + } + this._wrapper = this._register(wrapper); + this._subscribeToEvents(); + this._subscribeToSdkEvents(); + this._subscribeForMemoInvalidation(); + this._subscribeForInstructionsCollectedTelemetry(); + } + private _createRuntimeAdapter(): ICopilotSessionRuntime { return { + onSessionStarting: wrapper => this._installSessionEventHandlers(wrapper), chatUri: this._chatChannelUri, configurationResource: this._ownerSessionUri, handlePermissionRequest: this._guarded(request => this._handlePermissionRequest(request), { kind: 'reject' } satisfies PermissionRequestResult, 'permission'), @@ -2741,7 +2774,7 @@ export class CopilotAgentSession extends Disposable { return; } try { - await this._send(prompt, attachments, mode); + await this._send(prompt, attachments, mode, turn); } catch (err) { // A rejected send never reaches the SDK's agentic loop, so no // `session.idle` will ever arrive to close this turn. The host turns @@ -2798,7 +2831,7 @@ export class CopilotAgentSession extends Disposable { + paths.map(path => `- ${path}`).join('\n'); } - private async _send(prompt: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined): Promise { + private async _send(prompt: string, attachments: readonly MessageAttachment[] | undefined, mode: CopilotSdkMode | undefined, sendingTurn: CopilotTurn | undefined): Promise { this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); // Capture the turn's abort token before any dispatch await. Resolving a slash @@ -2945,23 +2978,38 @@ export class CopilotAgentSession extends Disposable { await this._prepareSdkTurn(mode); const traceContext = this._otelService.getSessionTraceContext(this.sessionId, this.resourceUri.toString()); - const sendingTurn = this._currentTurn.value; sendingTurn?.markProviderCallPending(); + const observations = this._wrapper.acceptsExternalMessages ? this._wrapper.bufferEventsUntilAcknowledged() : undefined; try { - await this._otelService.withTraceContext(traceContext, () => { + const sent = await this._otelService.withTraceContext(traceContext, () => { if (!this._environmentService.isBuilt && prompt === '$error') { return this._wrapper.session.rpc.sendMessages({ messages: [{ prompt }], requestHeaders: { Authorization: '******' }, }); } - return this._wrapper.session.send({ prompt, attachments: sdkAttachments?.length ? sdkAttachments : undefined }); + if (sdkAttachments?.some(attachment => attachment.type === 'extension_context')) { + return this._wrapper.session.rpc.sendMessages({ messages: [{ prompt, attachments: sdkAttachments }] }); + } + return this._wrapper.session.send({ prompt, attachments: sdkAttachments?.filter(attachment => attachment.type !== 'extension_context') }); }); + if (this._wrapper.acceptsExternalMessages && sendingTurn) { + const hostMessage = { turnId: sendingTurn.id, senderClientId: sendingTurn.senderClientId, clientContext: sendingTurn.clientContext, observed: false }; + for (const id of typeof sent === 'string' ? [sent] : sent.messageIds) { + this._hostMessageIds.set(id, hostMessage); + } + } sendingTurn?.markProviderCallResolved(); } catch (error) { sendingTurn?.markProviderCallRejected(); + try { + observations?.dispose(); + } catch (observationError) { + this._logService.error('[Copilot] Failed to reconcile observations after a rejected send', observationError); + } throw error; } + observations?.dispose(); this._logService.info(`[Copilot:${this.sessionId}] session.send() returned`); } @@ -3215,6 +3263,10 @@ export class CopilotAgentSession extends Disposable { * or just the selected text for a selection), so it is forwarded as-is without further slicing. */ private async _toSdkAttachment(attachment: MessageAttachment): Promise { + const extensionContext = readExtensionContext(attachment, this._chatChannelUri.toString()); + if (extensionContext) { + return extensionContext; + } if (isAgentFeedbackAnnotationsAttachment(attachment)) { const rendered = renderAgentFeedbackAnnotationsAttachment(attachment); if (!rendered) { @@ -3329,6 +3381,7 @@ export class CopilotAgentSession extends Disposable { return; } this._steeringMessagesInFlight.add(steeringMessage.id); + let observations: IDisposable | undefined; this._logService.info(`[Copilot:${this.sessionId}] Sending steering message: "${steeringMessage.message.text.substring(0, 100)}"`); try { await this._reconcileMcpServerEnablement(); @@ -3342,15 +3395,24 @@ export class CopilotAgentSession extends Disposable { const steeringPrompt = snapshotReminder ? `${steeringMessage.message.text}\n\n\n${snapshotReminder}\n` : steeringMessage.message.text; - await this._wrapper.session.send({ - prompt: steeringPrompt, - attachments: sdkAttachments?.length ? sdkAttachments : undefined, - mode: 'immediate', - }); + observations = this._wrapper.acceptsExternalMessages ? this._wrapper.bufferEventsUntilAcknowledged() : undefined; + const sent = sdkAttachments?.some(attachment => attachment.type === 'extension_context') + ? await this._wrapper.session.rpc.sendMessages({ messages: [{ prompt: steeringPrompt, attachments: sdkAttachments }], mode: 'immediate' }) + : await this._wrapper.session.send({ + prompt: steeringPrompt, + attachments: sdkAttachments?.filter(attachment => attachment.type !== 'extension_context'), + mode: 'immediate', + }); + if (this._wrapper.acceptsExternalMessages) { + for (const id of typeof sent === 'string' ? [sent] : sent.messageIds) { + this._steeringMessageIds.set(id, steeringMessage.id); + } + } } catch (err) { this._pendingSteeringFlips.delete(steeringMessage.id); this._logService.error(`[Copilot:${this.sessionId}] Steering message failed`, err); } finally { + observations?.dispose(); this._steeringMessagesInFlight.delete(steeringMessage.id); } } @@ -3422,7 +3484,23 @@ export class CopilotAgentSession extends Disposable { this._mappedEventsMemo = undefined; } - async abort(): Promise { + async abort(turnId?: string): Promise { + if (turnId && this._wrapper.acceptsExternalMessages) { + await this._wrapper.whenMessagesAcknowledged(); + if (this._currentTurn.value?.id !== turnId) { + const { items } = await this._wrapper.session.rpc.queue.pendingItems(); + const targets = items.filter(item => item.messageId && this._hostMessageIds.get(item.messageId)?.turnId === turnId); + for (const id of new Set(targets.map(item => item.id))) { + if (items.some(item => item.id === id && (!item.messageId || this._hostMessageIds.get(item.messageId)?.turnId !== turnId))) { + throw new Error('A queued batch contains another turn and cannot be cancelled as a unit.'); + } + await this._wrapper.session.rpc.queue.removeAt({ id }); + } + if (this._currentTurn.value?.id !== turnId) { + return; + } + } + } this._logService.info(`[Copilot:${this.sessionId}] Aborting session...`); const abortingTurn = this._currentTurn.value; const resumingTurn = this._resumingTurnAwaitingProviderStart; @@ -3788,6 +3866,9 @@ export class CopilotAgentSession extends Disposable { request: PermissionRequest, ): Promise { try { + if (this._wrapper?.acceptsExternalMessages) { + await this._wrapper.whenMessagesAcknowledged(); + } const toolCallId = request.toolCallId; if (!toolCallId) { // TODO: handle permission requests without a toolCallId by creating a synthetic tool call @@ -4513,6 +4594,9 @@ export class CopilotAgentSession extends Disposable { request: UserInputRequest, _invocation: { sessionId: string }, ): Promise { + if (this._wrapper?.acceptsExternalMessages) { + await this._wrapper.whenMessagesAcknowledged(); + } const requestId = generateUuid(); const questionId = generateUuid(); const inputRequest: ChatInputRequest = { @@ -5000,20 +5084,53 @@ export class CopilotAgentSession extends Disposable { if (e.data.source && e.data.source.toLowerCase() !== 'user') { return; } + if (wrapper.acceptsExternalMessages) { + if (this._observedNativeMessages.has(e.id)) { + return; + } + this._observedNativeMessages.set(e.id, true); + } // A genuine root user-message echo is the provider boundary for a // normal send. Zero-message continuation has no such echo and remains // quarantined until assistant.turn_start instead. this._dropLateRootTurnEvents = false; - // First SDK event for the loop: promote the turn out of `pending`. - this._currentTurn.value?.markRunning(); - const steering = this._takeMatchingPendingSteering(e.data.content); + const steeringId = e.data.messageId ? this._steeringMessageIds.get(e.data.messageId) : undefined; + const steering = wrapper.acceptsExternalMessages + ? steeringId ? this._pendingSteeringFlips.get(steeringId) : undefined + : this._takeMatchingPendingSteering(e.data.content); + const hostMessage = e.data.messageId ? this._hostMessageIds.get(e.data.messageId) : undefined; if (steering) { + this._pendingSteeringFlips.delete(steering.id); const turnId = this._beginSteeringTurn(steering); if (e.data.interactionId) { this._hostTurnIdsByInteractionId.set(e.data.interactionId, turnId); } + } else if (wrapper.acceptsExternalMessages && hostMessage && !e.data.isAutopilotContinuation) { + if (hostMessage.observed) { + return; + } + hostMessage.observed = true; + if (this._turnId !== hostMessage.turnId) { + this._completeActiveTurn(); + this.resetTurnState(hostMessage.turnId, hostMessage.senderClientId, hostMessage.clientContext.clientType, hostMessage.clientContext); + } + this._emitAction({ + type: ActionType.ChatTurnStarted, turnId: hostMessage.turnId, startedAt: e.timestamp, + message: { text: e.data.content, origin: { kind: MessageKind.User } }, + }); + } else if (wrapper.acceptsExternalMessages && !e.data.isAutopilotContinuation) { + // This is a genuine, unmatched runtime message, not an authenticated extension ID. + this._beginNativeTurn({ + text: e.data.content, origin: { kind: MessageKind.Tool }, + attachments: sdkAttachmentsToProtocol(e.data.attachments, this._chatChannelUri.toString()), + _meta: { copilotOrigin: CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN, sdkEventId: e.id }, + }, undefined, e.id); + if (this._turnId) { + void this._databaseRef.object.setTurnMessageOrigin(this._turnId, CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN).catch(error => this._logService.warn('[Copilot] Failed to persist native message provenance', error)); + } } if (this._turnId) { + this._currentTurn.value?.markRunning(); this._databaseRef.object.setTurnEventId(this._turnId, e.id); this._currentTurn.value?.completeEventId(e.id); } @@ -6181,7 +6298,9 @@ export class CopilotAgentSession extends Disposable { // when servers are configured at session-creation time), and there // is no replay. Subsequent `applyAll` calls from the event are // idempotent, so this safely converges either way. - this._seedMcpServersFromRpc(); + if (wrapper.isReady) { + this._seedMcpServersFromRpc(); + } } /** @@ -6196,6 +6315,9 @@ export class CopilotAgentSession extends Disposable { } private async _refreshMcpServersFromRpc(): Promise { + if (!this._wrapper.isReady) { + return; + } const mcpRpc = this._wrapper.session.rpc?.mcp; if (!mcpRpc) { return; @@ -6521,7 +6643,11 @@ export class CopilotAgentSession extends Disposable { void (async () => { let sources; try { - sources = (await wrapper.session.rpc.instructions.getSources()).sources; + const session = await wrapper.whenReady; + if (!session || this._store.isDisposed) { + return; + } + sources = (await session.rpc.instructions.getSources()).sources; } catch (err) { this._logService.trace(`[Copilot:${sessionId}] Failed to fetch instruction sources for telemetry: ${getErrorMessage(err)}`); return; @@ -6597,6 +6723,10 @@ export class CopilotAgentSession extends Disposable { const sessionId = this.sessionId; this._register(wrapper.onUnhandledEvent(e => { + if (e.type.startsWith('session.canvas.') || e.type === 'session.extensions.attachments_pushed') { + this._logService.trace(`[Copilot:${sessionId}] Runtime canvas notification: ${e.type}`); + return; + } this._logService.trace(`[Copilot:${sessionId}] Unhandled SDK event: ${safeStringify(e)}`); })); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts b/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts index 3fcc478adc0f9..93cc675bfc2ec 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAttachmentUtils.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { SimpleMessageAttachment } from '../../common/state/protocol/state.js'; +export { extensionContextToProtocol, readExtensionContext } from '../../common/meta/copilotCanvasMeta.js'; const attachmentDisplayKindParameter = 'x-vscode-display-kind='; const simpleAttachmentMimeType = 'text/x-vscode-simple-attachment'; diff --git a/src/vs/platform/agentHost/node/copilot/copilotCanvases.ts b/src/vs/platform/agentHost/node/copilot/copilotCanvases.ts new file mode 100644 index 0000000000000..d77e4d65c7e69 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotCanvases.ts @@ -0,0 +1,736 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CopilotClient, CopilotSession, ExtensionLaunchProviderHandler, ExtensionLaunchProviderResolveRequest, PermissionRequest, PermissionRequestResult, SessionEvent } from '@github/copilot-sdk'; +import { createHash } from 'crypto'; +import { realpath } from 'fs/promises'; +import { fileURLToPath, URL } from 'url'; +import { Barrier, IntervalTimer, raceCancellationError, timeout } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../base/common/errors.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, type IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { isAbsolute } from '../../../../base/common/path.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import type { IAgentCanvasApprovalClient, IAgentCanvasInstance, IAgentCanvasOperation, IAgentCanvasSnapshot, IAgentCanvases } from '../../common/agentHostCanvases.js'; +import { canvasIdentityKey, invalidCanvasParams, isBoundedCanvasJson, isCanvasIcon, isCanvasIdentity, isInlineCanvasSchema, validateCanvasActions, validateCanvasType } from '../../common/agentHostCanvasValidation.js'; +import type { InvokeCanvasActionParams, OpenCanvasParams } from '../../common/state/protocol/channels-canvas/commands.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasActionDeclaration, type CanvasIdentityKey, type CanvasSource, type CanvasSourcePresentation, type CanvasState, type CanvasTrustState, type CanvasTypeDeclaration } from '../../common/state/protocol/channels-canvas/state.js'; +import { AhpErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { IAgentHostCanvasesService } from '../agentHostCanvasesService.js'; +import { validateCanvasInput } from '../agentHostCanvasSchema.js'; +import type { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { sdkAttachmentsToProtocol } from './mapSessionEvents.js'; +import { isCopilotCanvasJson } from '../../common/meta/copilotCanvasMeta.js'; + +type NativeCanvas = Awaited>['canvases'][number]; +type NativeInstance = Awaited>['openCanvases'][number]; +type NativeCanvasEvent = Extract; + +export interface ICopilotCanvasHost { + prepare(chat: string, operation: IAgentCanvasOperation): Promise; + isBusy(chat: string): boolean; + residentChats(): readonly string[]; + recoverOwnedRuntime(): Promise; +} + +export interface ICopilotCanvasLaunch extends IDisposable { + readonly token: CancellationToken; + onEvent(event: SessionEvent): void; + permission(request: PermissionRequest): Promise; + attach(wrapper: CopilotSessionWrapper): Promise; +} + +interface ICanvasBacking { + readonly chat: string; + readonly sessionId: string; + clientId?: string; + initiator?: IAgentCanvasApprovalClient; + readonly store: DisposableStore; + readonly lifetime: CancellationTokenSource; + readonly extensionsLoaded: Barrier; + readonly sources: Map; + readonly pendingLaunches: Set; + readonly schemas: Map; + schemaLength: number; + readonly instances: Map; + readonly versions: Map; + readonly closed: Map; + readonly incarnations: Map; + session?: CopilotSession; + ready: boolean; + generation: string; + declarations: readonly NativeCanvas[]; + snapshot: IAgentCanvasSnapshot; + pendingEvents: NativeCanvasEvent[] | undefined; + pendingEventsLength: number; + failure?: string; +} + +function hasLiveCanvasSession(backing: ICanvasBacking | undefined): backing is ICanvasBacking & { session: CopilotSession } { + return backing?.session !== undefined && !backing.store.isDisposed && !backing.lifetime.token.isCancellationRequested; +} + +function isNativeCanvasEvent(event: SessionEvent): event is NativeCanvasEvent { + return event.type.startsWith('session.canvas.') || event.type === 'session.extensions_loaded' || event.type === 'session.shutdown'; +} + +function sourceId(source: CanvasSource): string { + if (source.kind !== CanvasSourceKind.Extension) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'This runtime source is not an admitted extension.'); + } + return source.extensionId; +} + +function sameNativeIdentity(left: NativeInstance, right: Pick): boolean { + return left.instanceId === right.instanceId && left.extensionId === right.extensionId && left.canvasId === right.canvasId; +} + +function nativeCanvasIdentity(chat: string, instance: Pick): CanvasIdentityKey { + return { chat, source: { kind: CanvasSourceKind.Extension, extensionId: instance.extensionId }, canvasType: instance.canvasId, instanceId: instance.instanceId }; +} + +/** One facet over the existing SDK client and its dispatcher, never a second SDK connection. */ +export class CopilotCanvases extends Disposable implements IAgentCanvases { + readonly defersHostTurnStart = true; + readonly instanceIdScope = 'chat'; + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + private readonly _backings = new Map(); + private readonly _sessions = new Map(); + private readonly _preparing = new Map(); + private readonly _connectionMonitor = this._register(new IntervalTimer()); + private _client: CopilotClient | undefined; + private _negotiated = false; + private _authorityLost = false; + private _readiness: Promise | undefined; + + constructor( + private readonly _host: ICopilotCanvasHost, + @IAgentHostCanvasesService private readonly _canvases: IAgentHostCanvasesService, + ) { + super(); + this._register(toDisposable(() => this.clientStopped())); + } + + get available(): boolean { return this._negotiated && !this.authorityLost && !this._store.isDisposed; } + get authorityLost(): boolean { + if (this._authorityLost) { + return true; + } + if (this._negotiated && this._client) { + try { + // The supported public getter rejects a closed connection; it never starts one. + void this._client.rpc; + } catch { + return true; + } + } + return false; + } + get readiness(): Promise | undefined { return this._readiness; } + + trackStartup(readiness: Promise): void { + this._readiness = readiness; + } + + readonly launchProvider: ExtensionLaunchProviderHandler = { + resolve: async (request, cancellation) => { + const backing = request.sessionId ? this._sessions.get(request.sessionId) : undefined; + const client = this._client; + if (!backing || backing.lifetime.token.isCancellationRequested || !client || !this.available || !request.defaultLaunch || !isAbsolute(request.modulePath) + || !request.id.startsWith(`${request.source}:`) || request.id.length > 256 || !request.name || request.name.length > 256 + || backing.pendingLaunches.has(request.id) || backing.pendingLaunches.size >= 128 + || !backing.sources.has(request.id) && backing.sources.size >= 1024 + || backing.sources.has(request.id) && backing.sources.get(request.id)?.modulePath !== request.modulePath) { + return { launch: null }; + } + backing.pendingLaunches.add(request.id); + const store = new DisposableStore(); + const lifetime = new CancellationTokenSource(backing.lifetime.token); + store.add(toDisposable(() => lifetime.dispose(true))); + if (cancellation) { + store.add(cancellation.onCancellationRequested(() => lifetime.cancel())); + if (cancellation.isCancellationRequested) { + lifetime.cancel(); + } + } + try { + const canonicalPath = await realpath(request.modulePath); + const approved = await this._canvases.requestApproval(backing.chat, this._sourcePrompt(request, canonicalPath), lifetime.token, backing.clientId, backing.initiator); + if (!approved || this._client !== client || !this.available || backing.store.isDisposed || await realpath(request.modulePath) !== canonicalPath) { + return { launch: null }; + } + // Top-level extension code is effectful. Retention must precede the launch recipe. + const retained = await raceCancellationError(client.rpc.session.retain({ sessionId: backing.sessionId }), lifetime.token); + if (retained !== null || lifetime.token.isCancellationRequested || backing.store.isDisposed || this._client !== client || !this.available) { + return { launch: null }; + } + await this._canvases.retainChat(backing.chat, lifetime.token); + if (lifetime.token.isCancellationRequested || backing.store.isDisposed || this._client !== client || !this.available) { + return { launch: null }; + } + backing.sources.set(request.id, { modulePath: request.modulePath, canonicalPath }); + return { launch: request.defaultLaunch }; + } finally { + backing.pendingLaunches.delete(request.id); + store.dispose(); + } + }, + }; + + clientStarting(client: CopilotClient): void { + if (this.authorityLost) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Canvas launch authority was lost. Explicit owned-runtime recovery is required.'); + } + this._client = client; + this._negotiated = false; + } + + clientStarted(client: CopilotClient): void { + if (this._client === client) { + // The supported public SDK refuses start without a v1 launch-provider acknowledgement. + this._negotiated = true; + this._connectionMonitor.cancelAndSet(() => { + if (this.authorityLost) { + this.loseAuthority(); + } + }, 1000); + } + } + + clientStopped(client?: CopilotClient): void { + if (client && this._client !== client) { + return; + } + this._connectionMonitor.cancel(); + this._negotiated = false; + this._client = undefined; + for (const backing of [...this._backings.values()]) { + backing.store.dispose(); + } + } + + loseAuthority(): void { + this._authorityLost = true; + this.clientStopped(); + } + + beginLaunch(sessionId: string, chat: string): ICopilotCanvasLaunch { + const operation = this._preparing.get(chat) ?? this._canvases.getChatInitialization(chat); + if (!this.available || this._sessions.has(sessionId) || this._backings.has(chat) || operation?.token.isCancellationRequested) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas backing is unavailable or already owned.'); + } + const store = new DisposableStore(); + const lifetime = new CancellationTokenSource(operation?.token); + store.add(toDisposable(() => lifetime.dispose(true))); + const generation = generateUuid(); + const backing: ICanvasBacking = { + chat, sessionId, clientId: operation?.clientId, initiator: operation?.initiator, store, lifetime, generation, sources: new Map(), pendingLaunches: new Set(), schemas: new Map(), instances: new Map(), versions: new Map(), closed: new Map(), incarnations: new Map(), + extensionsLoaded: new Barrier(), ready: false, schemaLength: 0, declarations: [], pendingEvents: [], pendingEventsLength: 0, snapshot: { chat, generation, types: [], instances: [] }, + }; + // Let child cancellation listeners run before disposing their parent emitter. + store.add(lifetime.token.onCancellationRequested(() => queueMicrotask(() => store.dispose()))); + store.add(toDisposable(() => { + lifetime.cancel(); + if (this._backings.get(chat) === backing) { + this._canvases.discardPendingAttachments(chat); + this._backings.delete(chat); + this._sessions.delete(sessionId); + backing.sources.clear(); + backing.pendingLaunches.clear(); + backing.schemas.clear(); + backing.schemaLength = 0; + backing.pendingEvents = undefined; + backing.pendingEventsLength = 0; + backing.incarnations.clear(); + backing.closed.clear(); + backing.versions.clear(); + backing.session = undefined; + backing.ready = false; + backing.instances.clear(); + backing.declarations = []; + backing.failure = undefined; + backing.generation = generateUuid(); + this._publish(backing); + } + })); + this._backings.set(chat, backing); + this._sessions.set(sessionId, backing); + return { + token: lifetime.token, + dispose: () => store.dispose(), + onEvent: event => { + if (store.isDisposed || event.agentId || backing.failure && event.type !== 'session.shutdown' && event.type !== 'session.extensions_loaded') { + return; + } + if (isNativeCanvasEvent(event)) { + if (backing.pendingEvents && event.type !== 'session.extensions_loaded' && event.type !== 'session.shutdown' && backing.pendingEvents.length < 1024) { + if (!isBoundedCanvasJson(event, 8 * 1024 * 1024) || (backing.pendingEventsLength += JSON.stringify(event).length) > 16 * 1024 * 1024) { + store.dispose(); + return; + } + backing.pendingEvents.push(event); + } else if (backing.pendingEvents && backing.pendingEvents.length >= 1024) { + store.dispose(); + return; + } + this._event(backing, event); + } else if (event.type === 'session.extensions.attachments_pushed') { + if (event.data.attachments.length <= 64 && isBoundedCanvasJson(event.data.attachments, 16 * 1024 * 1024) + && event.data.attachments.every(attachment => attachment.type === 'extension_context' ? backing.sources.has(attachment.extensionId) && isBoundedCanvasJson(attachment) : backing.sources.size > 0)) { + this._canvases.appendAttachments(chat, sdkAttachmentsToProtocol(event.data.attachments, chat) ?? []); + } + } + }, + permission: request => this._permission(backing, request), + attach: async wrapper => { + if (store.isDisposed) { + throw new CancellationError(); + } + backing.session = wrapper.session; + store.add(wrapper.onDidDispose(() => store.dispose())); + await raceCancellationError(backing.extensionsLoaded.wait(), lifetime.token); + await this._refresh(backing); + }, + }; + } + + getSnapshot(chat: string): IAgentCanvasSnapshot | undefined { + const backing = this._backings.get(chat); + return backing?.ready ? backing.snapshot : undefined; + } + + getTrust(chat: string, source: CanvasSource): CanvasTrustState { + const backing = this._backings.get(chat); + if (source.kind !== CanvasSourceKind.Extension || !this.available || !backing || backing.lifetime.token.isCancellationRequested || !backing.sources.has(source.extensionId)) { + return { status: CanvasTrustStatus.Pending }; + } + if (backing.failure) { + return { status: CanvasTrustStatus.Blocked, reason: backing.failure }; + } + return { status: CanvasTrustStatus.Trusted }; + } + + async initializeChat(chat: string, operation: IAgentCanvasOperation): Promise { + if (!this.available) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'An eligible canvas runtime is not connected.'); + } + if (!this._backings.get(chat)?.ready) { + operation.willExecute(); + this._preparing.set(chat, operation); + try { + await this._host.prepare(chat, operation); + } finally { + this._preparing.delete(chat); + } + } + const backing = this._backing(chat); + if (!backing.ready || backing.pendingEvents || backing.failure || operation.token.isCancellationRequested) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The canvas registry did not finish initializing.'); + } + } + + async prepare(identity: CanvasIdentityKey, operation: IAgentCanvasOperation): Promise { + await this.initializeChat(identity.chat, operation); + const backing = this._backing(identity.chat); + const deadline = Date.now() + 30_000; + while (!backing.snapshot.types.some(type => type.canvasType === identity.canvasType && sourceId(type.source) === sourceId(identity.source))) { + if (Date.now() >= deadline || backing.store.isDisposed) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'The admitted extension did not declare this canvas before the readiness deadline.'); + } + await raceCancellationError(timeout(50), operation.token); + } + } + + async open(params: OpenCanvasParams, operation: IAgentCanvasOperation): Promise { + const backing = this._backing(params.identity.chat); + const input = params.input; + if (input !== undefined && !isCopilotCanvasJson(input)) { + throw invalidCanvasParams('Canvas open input must be bounded JSON.'); + } + const native = { extensionId: sourceId(params.identity.source), canvasId: params.identity.canvasType, instanceId: params.identity.instanceId, ...(input === undefined ? {} : { input }) }; + const existing = backing.instances.get(native.instanceId); + if (existing && !sameNativeIdentity(existing, native)) { + throw new ProtocolError(AhpErrorCodes.Conflict, 'The native instance ID is already owned by a different canvas in this SDK session.'); + } + const version = backing.versions.get(native.instanceId) ?? 0; + backing.closed.delete(canvasIdentityKey(params.identity)); + operation.willExecute(); + const result = await raceCancellationError(backing.session.rpc.canvas.open(native), operation.token); + if (!sameNativeIdentity(result, native) || backing.store.isDisposed) { + throw new Error('The native canvas open did not settle against its original backing.'); + } + if ((backing.versions.get(native.instanceId) ?? 0) === version) { + backing.incarnations.set(native.instanceId, generateUuid()); + backing.instances.set(native.instanceId, result); + this._publish(backing); + } + const instance = backing.snapshot.instances.find(candidate => canvasIdentityKey(candidate.identity) === canvasIdentityKey(params.identity)); + if (!instance || backing.closed.has(canvasIdentityKey(params.identity))) { + throw new Error('The canvas closed while its open was pending.'); + } + return instance; + } + + async invoke(state: CanvasState, params: InvokeCanvasActionParams, operation: IAgentCanvasOperation): Promise { + const backing = this._backing(state.identity.chat); + this._instance(backing, state.identity); + const input = params.input; + if (input !== undefined && !isCopilotCanvasJson(input)) { + throw invalidCanvasParams('Canvas action input must be bounded JSON.'); + } + operation.willExecute(); + return raceCancellationError(backing.session.rpc.canvas.action.invoke({ instanceId: state.identity.instanceId, actionName: params.actionId, ...(input === undefined ? {} : { input }) }), operation.token); + } + + async close(state: CanvasState, operation: IAgentCanvasOperation): Promise { + const backing = this._backing(state.identity.chat); + const instance = this._instance(backing, state.identity); + operation.willExecute(); + await raceCancellationError(backing.session.rpc.canvas.close({ instanceId: state.identity.instanceId }), operation.token); + const current = backing.instances.get(state.identity.instanceId); + if (current && current !== instance) { + throw new Error('A different native instance appeared while close was pending.'); + } + backing.closed.set(canvasIdentityKey(state.identity), state.identity); + backing.instances.delete(state.identity.instanceId); + backing.incarnations.delete(state.identity.instanceId); + this._publish(backing); + } + + async restart(state: CanvasState, operation: IAgentCanvasOperation): Promise { + if (this.authorityLost || !hasLiveCanvasSession(this._backings.get(state.identity.chat))) { + const affected = this._host.residentChats(); + if (!await this._canvases.requestApproval(state.identity.chat, localize('canvas.recoverOwner', "Restart the owned preview runtime? This disconnects {0} resident chats. No canvas action or model turn will be replayed.", affected.length), operation.token, operation.clientId, operation.initiator)) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Owned-runtime recovery was not approved.'); + } + operation.willExecute(); + this._authorityLost = false; + try { + await this._host.recoverOwnedRuntime(); + await this.prepare(state.identity, operation); + } catch (error) { + this._authorityLost = true; + throw error; + } + return; + } + const backing = this._backing(state.identity.chat); + if (this._host.isBusy(backing.chat) || !await this._canvases.requestApproval(backing.chat, localize('canvas.reloadExtensions', "Reload all extensions in this chat? This replaces their live endpoints and prompts again before source execution. Retained workspace data is kept. No canvas action is replayed."), operation.token, operation.clientId, operation.initiator)) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'Extension reload requires an idle chat and explicit approval.'); + } + operation.willExecute(); + backing.clientId = operation.clientId; + backing.initiator = operation.initiator; + backing.generation = generateUuid(); + backing.failure = undefined; + backing.sources.clear(); + backing.schemas.clear(); + backing.schemaLength = 0; + backing.closed.clear(); + backing.versions.clear(); + backing.incarnations.clear(); + for (const [id, instance] of backing.instances) { + backing.instances.set(id, { instanceId: instance.instanceId, extensionId: instance.extensionId, canvasId: instance.canvasId, title: instance.title }); + } + this._publish(backing); + await raceCancellationError(backing.session.rpc.extensions.reload(), operation.token); + await this._refresh(backing); + } + + async resolve(state: CanvasState, clientId: string, token: CancellationToken): Promise { + const backing = this._backings.get(state.identity.chat); + const admission = state.identity.source.kind === CanvasSourceKind.Extension ? backing?.sources.get(state.identity.source.extensionId) : undefined; + if (!clientId || !backing || !admission || backing.failure || backing.lifetime.token.isCancellationRequested || !this.available || token.isCancellationRequested) { + return undefined; + } + // Check the source binding anew on every pull, without refreshing or starting its runtime. + if (await realpath(admission.modulePath) !== admission.canonicalPath || token.isCancellationRequested || backing.store.isDisposed || backing.failure || !this.available) { + return undefined; + } + const instance = this._instance(backing, state.identity); + if (!instance.url) { + return undefined; + } + const url = new URL(instance.url); + if (url.protocol === `${Schemas.file}:` && !url.username && !url.password && (!url.hostname || url.hostname === 'localhost')) { + const canonicalPath = await realpath(fileURLToPath(url)); + if (token.isCancellationRequested || backing.store.isDisposed || backing.lifetime.token.isCancellationRequested || backing.instances.get(instance.instanceId) !== instance) { + return undefined; + } + const canonical = new URL(URI.file(canonicalPath).toString()); + canonical.search = url.search; + canonical.hash = url.hash; + return { url: canonical.toString() }; + } + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.username || url.password) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, 'The runtime did not return an authorized loopback canvas endpoint.'); + } + return { url: instance.url }; + } + + async resolveSchema(chat: string, source: CanvasSource, reference: string): Promise { + const backing = this._backings.get(chat); + const admission = backing?.sources.get(sourceId(source)); + const schema = backing?.schemas.get(reference); + if (!backing || !admission || schema?.source !== sourceId(source) || this.getTrust(chat, source).status !== CanvasTrustStatus.Trusted + || await realpath(admission.modulePath) !== admission.canonicalPath || backing.sources.get(sourceId(source)) !== admission + || this.getTrust(chat, source).status !== CanvasTrustStatus.Trusted) { + return undefined; + } + return structuredClone(schema.schema); + } + + async validateInput(_chat: string, _source: CanvasSource, schema: object, input: unknown): Promise { + validateCanvasInput(schema, input); + } + + private _backing(chat: string): ICanvasBacking & { session: CopilotSession } { + const backing = this._backings.get(chat); + if (!this.available || !hasLiveCanvasSession(backing)) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'The exact canvas backing is not live.'); + } + return backing; + } + + private _instance(backing: ICanvasBacking, identity: CanvasIdentityKey): NativeInstance { + const instance = backing.instances.get(identity.instanceId); + if (!instance || instance.extensionId !== sourceId(identity.source) || instance.canvasId !== identity.canvasType || backing.closed.has(canvasIdentityKey(identity))) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'The native instance is not owned by this exact canvas identity.'); + } + return instance; + } + + private _sourcePrompt(request: ExtensionLaunchProviderResolveRequest, canonicalPath: string): string { + return localize('canvas.admitMutableSource', "Allow {0} ({1}) to execute from its original source at {2}? This grants mutable-directory trust for this launch, not approval of an immutable content revision. Its Node.js process is unsandboxed and can run top-level code, tools, hooks, and system-message contributions. This permission is separate from Workspace Trust. The preview runtime cancels unanswered launch requests after 15 seconds.", request.id, request.source, canonicalPath); + } + + private async _permission(backing: ICanvasBacking, request: PermissionRequest): Promise { + if (request.kind !== 'extension-env-access') { + return undefined; + } + if (!this.available || backing.store.isDisposed || !backing.sources.has(request.extensionName) || !request.environmentVariables.length || request.environmentVariables.length > 64 + || request.environmentVariables.some(name => !/^[A-Za-z_][A-Za-z0-9_]{0,255}$/.test(name))) { + return { kind: 'reject' }; + } + const approved = await this._canvases.requestApproval(backing.chat, localize('canvas.environmentAdmission', "Allow {0} to read these exact environment variable names: {1}? Values are never included in this request. This grant does not approve other names or extensions.", request.extensionName, [...new Set(request.environmentVariables)].sort().join(', ')), backing.lifetime.token, backing.clientId, backing.initiator); + return { kind: approved && !backing.store.isDisposed && this.available ? 'approve-once' : 'reject' }; + } + + private async _refresh(backing: ICanvasBacking): Promise { + const pending = backing.pendingEvents ?? []; + backing.pendingEvents = pending; + if (!hasLiveCanvasSession(backing)) { + throw new CancellationError(); + } + const session = backing.session; + try { + const [catalog, live] = await raceCancellationError(Promise.all([session.rpc.canvas.list(), session.rpc.canvas.listOpen()]), backing.lifetime.token); + if (!hasLiveCanvasSession(backing) || backing.session !== session) { + throw new CancellationError(); + } + if (catalog.canvases.length > 1024 || live.openCanvases.length > 64 || !isBoundedCanvasJson(catalog, 8 * 1024 * 1024) || !isBoundedCanvasJson(live, 8 * 1024 * 1024) + || new Set(live.openCanvases.map(instance => instance.instanceId)).size !== live.openCanvases.length) { + throw invalidCanvasParams('The native canvas snapshot exceeds its bound or repeats a native instance ID.'); + } + backing.declarations = catalog.canvases; + backing.instances.clear(); + for (const instance of live.openCanvases) { + if (!backing.closed.has(canvasIdentityKey(nativeCanvasIdentity(backing.chat, instance)))) { + backing.instances.set(instance.instanceId, instance); + if (!backing.incarnations.has(instance.instanceId)) { + backing.incarnations.set(instance.instanceId, generateUuid()); + } + } + } + for (const event of pending) { + this._event(backing, event, false, true); + } + backing.ready = true; + this._publish(backing); + } catch (error) { + if (!backing.store.isDisposed) { + this._fail(backing); + } + throw error; + } finally { + backing.pendingEvents = undefined; + backing.pendingEventsLength = 0; + } + } + + private _event(backing: ICanvasBacking, event: NativeCanvasEvent, publish = true, replay = false): void { + switch (event.type) { + case 'session.canvas.registry_changed': + backing.declarations = event.data.canvases; + break; + case 'session.extensions_loaded': + for (const extension of event.data.extensions) { + if (extension.status === 'disabled' || extension.status === 'failed') { + backing.sources.delete(extension.id); + } + } + backing.extensionsLoaded.open(); + break; + case 'session.canvas.opened': { + const existing = backing.instances.get(event.data.instanceId); + if (existing && !sameNativeIdentity(existing, event.data)) { + backing.store.dispose(); + return; + } + if (!replay) { + backing.versions.set(event.data.instanceId, (backing.versions.get(event.data.instanceId) ?? 0) + 1); + } + if (!replay || !backing.incarnations.has(event.data.instanceId)) { + backing.incarnations.set(event.data.instanceId, generateUuid()); + } + backing.closed.delete(canvasIdentityKey(nativeCanvasIdentity(backing.chat, event.data))); + backing.instances.set(event.data.instanceId, event.data); + break; + } + case 'session.canvas.unavailable': { + const instance = backing.instances.get(event.data.instanceId); + if (instance && sameNativeIdentity(instance, event.data)) { + if (!replay) { + backing.incarnations.set(instance.instanceId, generateUuid()); + backing.sources.delete(instance.extensionId); + } + backing.instances.set(instance.instanceId, { instanceId: instance.instanceId, extensionId: instance.extensionId, canvasId: instance.canvasId, title: instance.title }); + } + break; + } + case 'session.canvas.closed': + case 'session.canvas.removed': { + const instance = backing.instances.get(event.data.instanceId); + if (!instance || sameNativeIdentity(instance, event.data)) { + if (!replay) { + backing.versions.set(event.data.instanceId, (backing.versions.get(event.data.instanceId) ?? 0) + 1); + } + const identity = nativeCanvasIdentity(backing.chat, event.data); + backing.closed.set(canvasIdentityKey(identity), identity); + backing.instances.delete(event.data.instanceId); + backing.incarnations.delete(event.data.instanceId); + } + break; + } + case 'session.shutdown': + backing.store.dispose(); + return; + } + if (publish) { + this._publish(backing); + } + } + + private _schema(backing: ICanvasBacking, extensionId: string, key: string, schema: unknown): Pick { + if (schema === undefined) { + return {}; + } + if (isInlineCanvasSchema(schema)) { + return { inputSchema: schema }; + } + if (!isBoundedCanvasJson(schema, 1024 * 1024) || typeof schema !== 'object' || schema === null || Array.isArray(schema)) { + throw invalidCanvasParams('The native canvas schema cannot be represented within the reference bound.'); + } + const fingerprint = createHash('sha256').update(JSON.stringify([extensionId, key, schema])).digest('hex'); + const reference = `ahp-canvas-schema:/${backing.generation}/${fingerprint}`; + if (!backing.schemas.has(reference)) { + const length = JSON.stringify(schema).length; + if (backing.schemas.size >= 4096 || backing.schemaLength + length > 8 * 1024 * 1024) { + throw invalidCanvasParams('The native schema-reference registry is full.'); + } + backing.schemas.set(reference, { source: extensionId, schema: structuredClone(schema) }); + backing.schemaLength += length; + } + return { inputSchemaRef: reference }; + } + + private _publish(backing: ICanvasBacking): void { + try { + if (!backing.failure) { + this._publishLive(backing); + } + } catch { + this._fail(backing); + } + } + + private _fail(backing: ICanvasBacking): void { + const message = localize('canvas.invalidRuntimeState', "The runtime canvas declaration is invalid or exceeds the supported bounds. Explicit reload is required."); + backing.failure = message; + backing.generation = generateUuid(); + backing.schemas.clear(); + backing.schemaLength = 0; + backing.pendingEvents = undefined; + backing.pendingEventsLength = 0; + backing.snapshot = { + chat: backing.chat, generation: backing.generation, types: backing.snapshot.types, + instances: backing.snapshot.instances.map(instance => ({ + ...instance, generation: backing.generation, + availability: { status: CanvasAvailabilityStatus.Failed, error: { errorType: 'invalidCanvasDeclaration', message } }, + })), + }; + this._onDidChange.fire(backing.snapshot); + } + + private _publishLive(backing: ICanvasBacking): void { + if (backing.declarations.length > 1024 || backing.instances.size > 64 || backing.closed.size > 1024 + || !isBoundedCanvasJson(backing.declarations, 8 * 1024 * 1024)) { + throw invalidCanvasParams('The native canvas registry exceeds its bound.'); + } + const types: CanvasTypeDeclaration[] = []; + const typeKeys = new Set(); + for (const declaration of backing.declarations) { + if (!backing.sources.has(declaration.extensionId)) { + continue; + } + const key = JSON.stringify([declaration.extensionId, declaration.canvasId]); + if (typeKeys.has(key)) { + throw invalidCanvasParams('The native canvas registry repeats a source-qualified type.'); + } + typeKeys.add(key); + const schema = this._schema(backing, declaration.extensionId, `${declaration.canvasId}/open`, declaration.inputSchema); + const actions = (declaration.actions ?? []).map(action => ({ + id: action.name, ...(action.description === undefined ? {} : { description: action.description }), + ...this._schema(backing, declaration.extensionId, `${declaration.canvasId}/${action.name}`, action.inputSchema), + })); + validateCanvasActions(actions); + const icon = declaration.icon && isAbsolute(declaration.icon) ? { src: URI.file(declaration.icon).toString() } : undefined; + const type: CanvasTypeDeclaration = { + source: { kind: CanvasSourceKind.Extension, extensionId: declaration.extensionId }, canvasType: declaration.canvasId, + title: declaration.displayName, description: declaration.description, declaredActions: actions, + ...(schema.inputSchema ? { openInputSchema: schema.inputSchema } : {}), + ...(schema.inputSchemaRef ? { openInputSchemaRef: schema.inputSchemaRef } : {}), + ...(icon && isCanvasIcon(icon) ? { icon } : {}), + }; + validateCanvasType(type); + types.push(type); + } + const instances: IAgentCanvasInstance[] = []; + for (const instance of backing.instances.values()) { + const declaration = types.find(type => type.canvasType === instance.canvasId && sourceId(type.source) === instance.extensionId); + if (declaration) { + const projected: IAgentCanvasInstance = { + identity: { chat: backing.chat, source: declaration.source, canvasType: instance.canvasId, instanceId: instance.instanceId }, + generation: `${backing.generation}/${backing.incarnations.get(instance.instanceId)}`, + title: instance.title ?? declaration.title, ...(declaration.icon ? { icon: declaration.icon } : {}), + availability: instance.url + ? { status: CanvasAvailabilityStatus.Ready, actions: declaration.declaredActions ?? [] } + : { status: CanvasAvailabilityStatus.NotLoaded }, + }; + if (!isCanvasIdentity(projected.identity) || projected.title.length > 4096) { + throw invalidCanvasParams('The native canvas instance exceeds its identity or display bound.'); + } + instances.push(projected); + } + } + backing.snapshot = { chat: backing.chat, generation: backing.generation, types, instances, closed: [...backing.closed.values()] }; + this._onDidChange.fire(backing.snapshot); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index c3022afe470dd..c416aa46818c3 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -8,6 +8,7 @@ import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { isObject, isStringArray } from '../../../../base/common/types.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; +import { raceCancellationError } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; @@ -35,7 +36,8 @@ import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyServi import type { ICopilotMcpServerInfo, ICopilotPluginInfo } from './copilotAgent.js'; import { CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js'; import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js'; -import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import { CopilotSessionEventBuffer, CopilotSessionWrapper } from './copilotSessionWrapper.js'; +import type { CopilotCanvases, ICopilotCanvasLaunch } from './copilotCanvases.js'; import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js'; import { isAutoModel, isGpt56Model } from './modelIdentifiers.js'; import { EPHEMERAL_DISABLED_COPILOT_TOOLS } from './copilotToolDisplay.js'; @@ -190,6 +192,8 @@ export function toSdkToolFilterPatterns(patterns: readonly string[] | undefined) } export interface ICopilotSessionRuntime { + /** Installs host event handlers before effectful native extension initialization. */ + onSessionStarting?(wrapper: CopilotSessionWrapper): void; /** Chat channel that owns this session's turns, used to attribute terminal claims. */ readonly chatUri: URI; /** Opaque scope shared by chats whose session configuration is shared. */ @@ -617,6 +621,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { private _byokProxyHandle: Promise | undefined; constructor( + private readonly _canvases: CopilotCanvases | undefined = undefined, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostManagedSettingsService private readonly _managedSettingsService: IAgentHostManagedSettingsService, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, @@ -629,8 +634,31 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ) { } async launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise { + const canvas = plan.isEphemeral ? undefined : this._canvases?.beginLaunch(plan.sessionId, runtime.chatUri.toString()); + let wrapper: CopilotSessionWrapper | undefined; + const cancellation = canvas?.token.onCancellationRequested(() => wrapper?.dispose()); + try { + if (canvas && runtime.onSessionStarting) { + wrapper = new CopilotSessionWrapper(plan.sessionId); + runtime.onSessionStarting(wrapper); + } + const launched = this._launch(plan, runtime, canvas, wrapper); + wrapper = await (canvas ? raceCancellationError(launched, canvas.token) : launched); + await canvas?.attach(wrapper); + return wrapper; + } catch (error) { + canvas?.dispose(); + wrapper?.dispose(); + throw error; + } finally { + cancellation?.dispose(); + } + } + + private async _launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime, canvas: ICopilotCanvasLaunch | undefined, pendingWrapper?: CopilotSessionWrapper): Promise { let managedSettingsResolved = false; - const config = await this._buildSessionConfig(plan, runtime, () => { managedSettingsResolved = true; }); + const earlyEvents = canvas && !pendingWrapper ? new CopilotSessionEventBuffer() : undefined; + const config = await this._buildSessionConfig(plan, runtime, () => { managedSettingsResolved = true; }, canvas, earlyEvents, pendingWrapper); const sandboxConfig = () => { if (!managedSettingsResolved) { this._logService.error(`[Copilot:${plan.sessionId}] Copilot runtime did not report its resolved managed settings; continuing with available sandbox configuration`); @@ -638,7 +666,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return this._computeSandboxConfig(runtime.configurationResource.toString()); }; if (plan.kind === 'create') { - return this._createSession(plan, config, sandboxConfig); + return this._createSession(plan, config, sandboxConfig, earlyEvents, pendingWrapper); } let fallbackPlan = plan; @@ -649,7 +677,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.trace(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); const raw = await this._resumeSession(session, plan, config); this._logService.trace(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded after ${stopWatch.elapsed()}ms`); - return this._finalizeSession(raw, sandboxConfig, plan.sessionId, plan.fallback.model?.id); + return this._finalizeSession(raw, sandboxConfig, plan.sessionId, plan.fallback.model?.id, earlyEvents, pendingWrapper); } catch (err) { let resumeError = err; const errCode = getCopilotSdkErrorCode(resumeError); @@ -661,7 +689,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.warn(`[Copilot:${plan.sessionId}] Stored custom agent '${plan.resolvedAgentName}' was not found; retrying resume without a custom agent`); try { const raw = await this._resumeSession(session, fallbackPlan, fallbackConfig); - return this._finalizeSession(raw, sandboxConfig, plan.sessionId, fallbackPlan.fallback.model?.id); + return this._finalizeSession(raw, sandboxConfig, plan.sessionId, fallbackPlan.fallback.model?.id, earlyEvents, pendingWrapper); } catch (retryErr) { resumeError = retryErr; this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession without custom agent failed: code=${getCopilotSdkErrorCode(retryErr)}, message=${getErrorMessage(retryErr)}`); @@ -682,7 +710,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { model: fallbackPlan.fallback.model, longContextWindow: fallbackPlan.fallback.longContextWindow, freeLongContext: fallbackPlan.fallback.freeLongContext, - }, fallbackConfig, sandboxConfig); + }, fallbackConfig, sandboxConfig, earlyEvents, pendingWrapper); this._sessionOpenTelemetry.sdkResumeFallbackCreated(session); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; @@ -701,7 +729,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return this._otelService.withTraceContext(this._otelService.getSessionTraceContext(sessionId, sessionUri), fn); } - private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: () => SandboxConfig): Promise { + private async _createSession(plan: ICopilotCreateSessionLaunchPlan, config: ResumeSessionConfig, sandboxConfig: () => SandboxConfig, earlyEvents?: CopilotSessionEventBuffer, pendingWrapper?: CopilotSessionWrapper): Promise { const raw = await this._withTraceContext(plan.sessionId, () => plan.client.createSession({ ...config, sessionId: plan.sessionId, @@ -712,10 +740,10 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, })); - return this._finalizeSession(raw, sandboxConfig, plan.sessionId, plan.model?.id); + return this._finalizeSession(raw, sandboxConfig, plan.sessionId, plan.model?.id, earlyEvents, pendingWrapper); } - private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: () => SandboxConfig, sessionId: string, modelId: string | undefined): Promise { + private async _finalizeSession(raw: CopilotSessionWrapper['session'], sandboxConfig: () => SandboxConfig, sessionId: string, modelId: string | undefined, earlyEvents?: CopilotSessionEventBuffer, pendingWrapper?: CopilotSessionWrapper): Promise { try { await this._applyScriptSafety(raw, sessionId); await applySandboxConfig(raw, sandboxConfig(), sessionId, this._logService); @@ -730,33 +758,14 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { if (isGpt56Model(modelId)) { await this._applyGpt56Customizations(raw, sessionId); } - return new CopilotSessionWrapper(raw); + if (pendingWrapper) { + await pendingWrapper.attachSession(raw); + return pendingWrapper; + } + return new CopilotSessionWrapper(raw, earlyEvents); } - /** - * Enables the runtime's shell-script safety classifier, which managed permissions - * depend on to govern shell operations. - * - * Without it the runtime short-circuits the classifier, so a shell command reaches - * the permission layer with an empty `possiblePaths` and `hasWriteFileRedirection: - * false`. Managed `Read(...)`/`Edit(...)` rules then cannot match a redirect target, - * letting `echo ... >> denied/path` bypass a managed deny. The Copilot CLI opts in at - * session creation; the SDK exposes it to hosts only through `options.update`, so it - * is applied here to cover both created and resumed sessions. - * - * This fails the launch closed unconditionally. The host cannot tell whether a - * session is policy-bearing: `IAgentHostManagedSettingsService` only carries the - * legacy VS Code settings bridge, which is itself behind a false-by-default - * compatibility setting, while server and MDM policy is discovered by the runtime - * itself under `enableManagedSettings`. Gating a security control on that signal - * would leave exactly the enterprise sessions it protects unprotected, so the - * option is treated as required for every session. - * - * The client-level `managedSettings.read` is not a usable substitute: it discovers - * only device sources (MDM and managed-file), so a session governed solely by - * GitHub org policy would still read as unmanaged. Approximating the boundary is - * worse than not drawing one. - */ + /** Reaffirms runtime shell-script classification after startup and fails launch if it is rejected. */ private async _applyScriptSafety(session: CopilotSessionWrapper['session'], sessionId: string): Promise { try { const result = await session.rpc.options.update({ enableScriptSafety: true }); @@ -768,7 +777,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // problems by failing the request, so this is the path a genuine failure // takes. Log the reason before it propagates: the launch is aborted below // and the raw RPC error alone would not say which option was refused. - this._logService.error(`[Copilot:${sessionId}] Could not enable script safety; managed permissions cannot govern shell paths`, err); + this._logService.error(`[Copilot:${sessionId}] Could not enable script safety classification`, err); throw err; } } @@ -854,7 +863,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } } - private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime, onManagedSettingsResolved: () => void): Promise { + private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime, onManagedSettingsResolved: () => void, canvas: ICopilotCanvasLaunch | undefined, earlyEvents?: CopilotSessionEventBuffer, pendingWrapper?: CopilotSessionWrapper): Promise { const plugins = plan.snapshot.plugins; // Synthesize BYOK provider/model config (empty when BYOK is gated off or the // renderer reports no BYOK models), merged into the returned config so both @@ -973,6 +982,11 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { ...byok, ...disabledMcpServers, onEvent: event => { + earlyEvents?.capture(event); + if (pendingWrapper && !pendingWrapper.isReady) { + pendingWrapper.acceptSessionEvent(event); + } + canvas?.onEvent(event); const owner = runtime.configurationResource.toString(); if (event.type === 'session.managed_settings_resolved' && !event.agentId) { this._configurationService.setSessionSandboxPolicy(owner, projectCopilotSandboxPolicy(event.data)); @@ -994,8 +1008,10 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { githubMcpToolConfig: { disableFormDeferral: true }, enableFileHooks: true, enableConfigDiscovery: true, - requestExtensions: false, // force-disable copilot extension management tools (otherwise enabled in experimental mode) - onPermissionRequest: request => runtime.handlePermissionRequest(request), + enableScriptSafety: true, + requestExtensions: canvas !== undefined, + ...(canvas ? { requestCanvasRenderer: true } : {}), + onPermissionRequest: async request => await canvas?.permission(request) ?? runtime.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), onElicitationRequest: context => runtime.handleElicitationRequest(context), onMcpAuthRequest: (request, context) => runtime.handleMcpAuthRequest(request, context), diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index 59e5df76c599d..9cda8c1dd3a7d 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -5,10 +5,52 @@ import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; import { DeferredPromise } from '../../../../base/common/async.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, toDisposable, type IDisposable } from '../../../../base/common/lifecycle.js'; import type { AgentTurnProviderSessionState } from '../../common/agent.js'; +/** Live SDK notifications received before the owning chat has installed its handlers. */ +export class CopilotSessionEventBuffer { + private _events: SessionEvent[] = []; + private _length = 0; + private _overflow = false; + private _claimed = false; + + capture(event: SessionEvent): void { + if (!this._claimed) { + this.append(event); + } + } + + claim(): void { + this._claimed = true; + } + + append(event: SessionEvent): void { + if (this._overflow) { + return; + } + this._length += JSON.stringify(event).length; + if (this._events.length >= 1024 || this._length > 8 * 1024 * 1024) { + this._events = []; + this._overflow = true; + return; + } + this._events.push(event); + } + + take(): readonly SessionEvent[] { + if (this._overflow) { + throw new Error('Early SDK notifications exceeded the bounded buffer. The chat must be reconciled without replaying effects.'); + } + const events = this._events; + this._events = []; + this._length = 0; + return events; + } +} + export type CopilotModelCallFinishedOutcome = 'success' | 'error' | 'cancelled' | 'rejected'; export interface ICopilotModelCallFinishedEvent { @@ -37,29 +79,147 @@ export class CopilotSessionWrapper extends Disposable { private readonly _onModelCallFinished = this._register(new Emitter()); readonly onModelCallFinished = this._onModelCallFinished.event; private readonly _shutdown = new DeferredPromise(); + private readonly _onDidDispose = this._register(new Emitter()); + readonly onDidDispose = this._onDidDispose.event; private _disconnectPromise: Promise | undefined; private _disconnectCompleted = false; - - constructor(readonly session: CopilotSession) { + private readonly _eventDispatchers = new Map void>(); + private readonly _ready = new DeferredPromise(); + readonly whenReady = this._ready.p; + private readonly _sessionId: string; + private _session: CopilotSession | undefined; + private _earlyEvents: CopilotSessionEventBuffer | undefined; + private _acknowledgements = 0; + private _acknowledged: DeferredPromise | undefined; + private _observationError: unknown; + private _observationFailed = false; + readonly acceptsExternalMessages: boolean; + + constructor(session: CopilotSession | string, earlyEvents?: CopilotSessionEventBuffer) { super(); - const unsubscribeAll = session.on(event => { - if (event.type === 'session.shutdown') { - void this._shutdown.complete(); - } - const modelCallFinished = parseModelCallFinishedEvent(event); - if (modelCallFinished) { - this._onModelCallFinished.fire(modelCallFinished); - } else if (!this._handledEventTypes.has(event.type)) { - this._onUnhandledEvent.fire(event); - } - }); - this._register(toDisposable(unsubscribeAll)); + this._sessionId = typeof session === 'string' ? session : session.sessionId; + this._earlyEvents = earlyEvents; + earlyEvents?.claim(); + this.acceptsExternalMessages = typeof session === 'string' || earlyEvents !== undefined; + this._register(toDisposable(() => { this._earlyEvents = undefined; })); + if (typeof session !== 'string') { + this._attach(session); + } this._register(toDisposable(() => { void this.disconnect().catch(() => { /* best-effort */ }); })); } - get sessionId(): string { return this.session.sessionId; } + get isReady(): boolean { return this._session !== undefined; } + get session(): CopilotSession { + if (!this._session) { + throw new Error('The SDK session is not ready.'); + } + return this._session; + } + + /** Attaches the public SDK object after create/resume; early events already have live host listeners. */ + async attachSession(session: CopilotSession): Promise { + if (this._store.isDisposed) { + await session.disconnect(); + throw new CancellationError(); + } + if (this._session || session.sessionId !== this._sessionId) { + throw new Error('The SDK session does not match its pending event owner.'); + } + this._attach(session); + } + + private _attach(session: CopilotSession): void { + this._session = session; + this._register(toDisposable(session.on(event => this.acceptSessionEvent(event)))); + void this._ready.complete(session); + } + + acceptSessionEvent(event: SessionEvent): void { + if (this._store.isDisposed) { + return; + } + if (event.type === 'session.shutdown') { + void this._shutdown.complete(); + } + if (this._earlyEvents) { + this._earlyEvents.append(event); + } else { + this._dispatch(event); + } + } + + /** Defers observations until a send acknowledgement supplies its real SDK message IDs. */ + bufferEventsUntilAcknowledged(): IDisposable { + if (this._earlyEvents && !this._acknowledged) { + throw new Error('The SDK observation buffer already has an owner.'); + } + this._acknowledged ??= new DeferredPromise(); + this._earlyEvents ??= new CopilotSessionEventBuffer(); + this._earlyEvents.claim(); + this._acknowledgements++; + return toDisposable(() => { + if (--this._acknowledgements === 0) { + const acknowledged = this._acknowledged; + this._acknowledged = undefined; + try { + this.releaseBufferedEvents(); + } catch (error) { + this._observationError = error; + this._observationFailed = true; + throw error; + } finally { + void acknowledged?.complete(); + } + } + }); + } + + async whenMessagesAcknowledged(): Promise { + await this._acknowledged?.p; + if (this._observationFailed) { + throw this._observationError; + } + if (this._store.isDisposed) { + throw new CancellationError(); + } + } + + /** Replays observations only, after all chat handlers exist; never reissues an SDK operation. */ + releaseBufferedEvents(): void { + if (!this._earlyEvents || this._acknowledgements > 0) { + return; + } + const buffer = this._earlyEvents; + this._earlyEvents = undefined; + const events = buffer.take(); + for (const event of events) { + this._dispatch(event); + } + } + + private _dispatch(event: SessionEvent): void { + const modelCallFinished = parseModelCallFinishedEvent(event); + if (modelCallFinished) { + this._onModelCallFinished.fire(modelCallFinished); + } else if (!this._handledEventTypes.has(event.type)) { + this._onUnhandledEvent.fire(event); + } + this._eventDispatchers.get(event.type)?.(event); + } + + get sessionId(): string { return this._sessionId; } + override dispose(): void { + if (!this._store.isDisposed) { + void this._acknowledged?.complete(); + if (!this._ready.isSettled) { + void this._ready.complete(undefined); + } + this._onDidDispose.fire(); + } + super.dispose(); + } get lifecycleState(): AgentTurnProviderSessionState { return this._shutdown.isSettled ? 'shutdown' @@ -72,6 +232,10 @@ export class CopilotSessionWrapper extends Disposable { /** Disconnects once the request completes or the SDK reports session shutdown. */ disconnect(): Promise { + if (!this._session) { + this._disconnectCompleted = true; + return Promise.resolve(); + } if (this._shutdown.isSettled) { return this._shutdown.p; } @@ -361,8 +525,13 @@ export class CopilotSessionWrapper extends Disposable { onDidAddFirstListener: () => this._handledEventTypes.add(eventType), onDidRemoveLastListener: () => this._handledEventTypes.delete(eventType), })); - const unsubscribe = this.session.on(eventType, (data: SessionEventPayload) => emitter.fire(data)); - this._register(toDisposable(unsubscribe)); + const matches = (event: SessionEvent): event is SessionEventPayload => event.type === eventType; + this._eventDispatchers.set(eventType, event => { + if (matches(event)) { + emitter.fire(event); + } + }); + this._register(toDisposable(() => this._eventDispatchers.delete(eventType))); return emitter.event; } } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 23d417ef13972..472380a311da1 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -23,7 +23,7 @@ import { getMediaMime } from '../../../../base/common/mime.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { buildChatErrorInfoFromCopilotSdkFields } from './copilotSdkChatError.js'; import { buildMcpChannel, buildMcpTopLevelCustomizationId } from '../shared/mcpCustomizationController.js'; -import { readSimpleAttachmentDisplayKindFromMimeType } from './copilotAttachmentUtils.js'; +import { extensionContextToProtocol, readSimpleAttachmentDisplayKindFromMimeType } from './copilotAttachmentUtils.js'; function tryStringify(value: unknown): string | undefined { try { @@ -550,7 +550,7 @@ export async function mapSessionEvents( const d = e.data; const messageId = d.interactionId ?? ''; const content = stripPromptScaffolding(d.content ?? ''); - const attachments = sdkAttachmentsToProtocol(d.attachments); + const attachments = sdkAttachmentsToProtocol(d.attachments, routingChatUri.toString()); // User messages carry no deprecated `parentToolCallId`; route // sub-agent user messages by the envelope `agentId` only. const parentToolCallId = resolveParentToolCallId(e.agentId, undefined); @@ -846,15 +846,16 @@ export async function mapSessionEvents( * copy of the bytes / paths it actually saw on send, which is the * authoritative record for replay. */ -function sdkAttachmentsToProtocol( +export function sdkAttachmentsToProtocol( attachments: readonly Attachment[] | undefined, + chat?: string, ): MessageAttachment[] | undefined { if (!attachments?.length) { return undefined; } const out: MessageAttachment[] = []; for (const a of attachments) { - const converted = sdkAttachmentToProtocol(a); + const converted = sdkAttachmentToProtocol(a, chat); if (converted) { out.push(converted); } @@ -864,8 +865,11 @@ function sdkAttachmentsToProtocol( function sdkAttachmentToProtocol( attachment: Attachment, + chat: string | undefined, ): MessageAttachment | undefined { switch (attachment.type) { + case 'extension_context': + return extensionContextToProtocol(attachment, chat); case 'file': { return { type: MessageAttachmentKind.Resource, diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts index 02bb256d26cbe..0a8aa8f42cd14 100644 --- a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts @@ -179,6 +179,11 @@ export class AgentHostLocalCommands extends Disposable { return undefined; } + /** Tests command recognition without executing its returned handling. */ + canHandle(request: ILocalChatCommandRequest): boolean { + return this._commands.some(command => command.tryHandle(request) !== undefined); + } + private async _run(command: ILocalChatCommand, handling: ILocalChatCommandHandling, request: ILocalChatCommandRequest): Promise { const stopWatch = StopWatch.create(false); try { diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index f575aa78411ea..2d8016b6eddaa 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { disposableTimeout } from '../../../base/common/async.js'; +import type { CancellationToken } from '../../../base/common/cancellation.js'; import { encodeBase64 } from '../../../base/common/buffer.js'; import { Emitter } from '../../../base/common/event.js'; import { isJsonRpcResponse } from '../../../base/common/jsonRpcProtocol.js'; @@ -21,7 +22,7 @@ import { AgentSession, type IAgentCreateChatRequestOptions, type IMcpNotificatio import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { isAnnotationsUri } from '../common/annotationsUri.js'; import { type IAgentService } from '../common/agentService.js'; -import { ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, removeSessionArtifactParamsValidator, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; +import { CancelAgentHostCanvasApprovalExtensionMethod, CancelCanvasChatInitializationExtensionMethod, ClaimAgentHostDetachedWorktreeExtensionMethod, collectAgentHostDebugLogsParamsValidator, CollectAgentHostDebugLogsExtensionMethod, CreateAgentHostDetachedWorktreeExtensionMethod, DeleteAgentHostDetachedWorktreeExtensionMethod, getAgentHostExtensionInitializeResultMeta, GetAgentHostSessionStateFileExtensionMethod, InitializeCanvasChatExtensionMethod, initializeCanvasChatParamsValidator, ReadAgentHostDebugLogsChunkExtensionMethod, ReconcileAgentHostDetachedWorktreesExtensionMethod, RemoveSessionArtifactExtensionMethod, removeSessionArtifactParamsValidator, RequestAgentHostCanvasApprovalExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, SetAgentHostDetachedWorktreeArchivedExtensionMethod, type IAgentHostCanvasApprovalRequest, type IAgentHostExtensionInitializeResult, type IAgentHostExtensionServerCommandMap, type IAgentHostWorkspaceTrustRequest } from '../common/agentHostExtensionProtocol.js'; import { isAgentDevContainerWorktreeHandle } from '../common/meta/agentDevContainerWorktreeMeta.js'; import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; @@ -71,6 +72,8 @@ import type { Implementation } from '../common/state/protocol/common/commands.js import { AGENT_HOST_CLIENT_CONNECTION_HISTORY_RETENTION, IAgentHostClientConnectionService, type IAgentHostClientConnectionSource } from './agentHostClientConnectionService.js'; import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; import { isAgentHostTelemetryService } from './agentHostTelemetryService.js'; +import { AHP_CANVAS_SCHEME, isCanvasMethod, isCanvasResource } from '../common/agentHostCanvasValidation.js'; +import { IAgentHostCanvasesService, type IAgentHostCanvasConnection } from './agentHostCanvasesService.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; @@ -221,6 +224,8 @@ interface IConnectedClient { readonly subscriptions: Map; readonly disposables: DisposableStore; readonly initializationDisposables: DisposableStore; + canvases: IAgentHostCanvasConnection | undefined; + readonly canvasApproval: boolean; } /** @@ -262,6 +267,8 @@ interface IGraceClientRecord { readonly clientInfo: Implementation | undefined; readonly telemetryContext: IAgentHostClientTelemetryContext | undefined; readonly protocolVersion: string | undefined; + readonly canvasCapability: boolean; + readonly canvasApproval: boolean; /** * Epoch ms when the client last had a live transport, or when this record * was created for a never-connected orphan tool-call stamp. Pins the grace @@ -316,7 +323,8 @@ export interface IProtocolServerConfig { /** Default directory returned to clients during the initialize handshake. */ readonly defaultDirectory?: string; /** - * Whether to expose VS Code extension methods outside the Agent Host Protocol. + * Whether to expose VS Code host management extension methods. + * Negotiated canvas data-plane extensions are governed by the connection's canvas capability. * Defaults to `true` for existing remote listeners. */ readonly allowExtensionMethods?: boolean; @@ -390,6 +398,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien @ITelemetryService private readonly _telemetryService: ITelemetryService, @IAgentHostManagedSettingsService private readonly _managedSettingsService: IAgentHostManagedSettingsService, @IAgentHostClientConnectionService private readonly _clientConnections: IAgentHostClientConnectionService, + @IAgentHostCanvasesService private readonly _canvases: IAgentHostCanvasesService, ) { super(); this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); @@ -537,7 +546,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien `Unsupported action: ${action.type}`, ); } else if (isSessionAction(action) || isChatAction(action) || isTerminalAction(action) || isChangesetAction(action) || isAnnotationsAction(action) || isAutomationAction(action) || isAutomationRunAction(action) || action.type === ActionType.RootConfigChanged) { - this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq, client.telemetryContext); + this._agentService.dispatchAction(channel, action, client.clientId, msg.params.clientSeq, client.telemetryContext, client.canvases?.initiator); } } break; @@ -576,6 +585,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien clientInfo: record.clientInfo, telemetryContext: client.telemetryContext, protocolVersion: client.protocolVersion, + canvasCapability: client.canvases !== undefined, + canvasApproval: client.canvasApproval, lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap(), }); @@ -601,10 +612,10 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const offered = Array.isArray(params.protocolVersions) ? params.protocolVersions : []; this._logService.info(`[ProtocolServer] Initialize: clientId=${params.clientId}, protocolVersions=[${offered.join(', ')}]`); - const negotiated = negotiateProtocolVersion(offered, PROTOCOL_VERSION); + const negotiated = negotiateProtocolVersion(offered, PROTOCOL_VERSION) ?? negotiateProtocolVersion(offered, '0.9.0'); if (!negotiated) { const data: UnsupportedProtocolVersionErrorDataEx = { - supportedVersions: [`^${PROTOCOL_VERSION}`], + supportedVersions: [`^${PROTOCOL_VERSION}`, '^0.9.0'], // Only advertise the in-band upgrade method when the agent // host was spawned by a VS Code CLI that is listening for // management requests (presence of the env var). Otherwise @@ -637,6 +648,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien subscriptions: new Map(), disposables, initializationDisposables, + canvases: negotiated === '0.10.0' && params.capabilities?.canvases && this._canvases.available + ? initializationDisposables.add(this._canvases.connect(params.clientId, (request, token) => this._requestCanvasApproval(client, request, token))) : undefined, + canvasApproval: negotiated === '0.10.0' && params.capabilities?.canvases !== undefined, }; this._attachConnection(params.clientId, client); try { @@ -644,9 +658,19 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const snapshots: IStateSnapshot[] = []; const pendingSnapshots: Promise[] = []; + const readiness = !client.canvases && client.canvasApproval ? this._canvases.readiness : undefined; + const canvasReady = readiness?.then(() => { + if (!initializationDisposables.isDisposed && this._canvases.available) { + client.canvases = initializationDisposables.add(this._canvases.connect(params.clientId, (request, token) => this._requestCanvasApproval(client, request, token))); + } + }); + if (canvasReady) { + pendingSnapshots.push(canvasReady); + } if (params.initialSubscriptions) { for (const uri of params.initialSubscriptions) { - const snapshot = this._addInitialSubscription(client, uri.toString()); + const snapshot = canvasReady && isCanvasResource(uri.toString()) + ? canvasReady.then(() => this._addInitialSubscription(client, uri.toString())) : this._addInitialSubscription(client, uri.toString()); if (snapshot instanceof Promise) { pendingSnapshots.push(snapshot.then(value => { if (value) { @@ -676,21 +700,25 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } this._onDidChangeConnectionCount.fire(this._connectedClientCount); + const canRemoveSessionArtifact = this._config.allowExtensionMethods !== false && !!this._agentService.removeSessionArtifact; const response: IAgentHostExtensionInitializeResult = { protocolVersion: negotiated, serverSeq: this._stateManager.serverSeq, - _meta: getAgentHostExtensionInitializeResultMeta(this._config.allowExtensionMethods !== false && !!this._agentService.removeSessionArtifact), + _meta: getAgentHostExtensionInitializeResultMeta(client.canvases !== undefined, canRemoveSessionArtifact), snapshots, defaultDirectory: this._config.defaultDirectory, completionTriggerCharacters: this._config.completionTriggerCharacters ? [...this._config.completionTriggerCharacters] : undefined, terminalCommandPrefix: this._config.terminalCommandPrefix, telemetry: this._config.otlpLogEmitter ? { logs: OTLP_LOGS_CHANNEL_TEMPLATE } : undefined, automations: this._agentService.automationCapabilities, + ...(client.canvases ? { canvases: {} } : {}), }; return { client, response: pendingSnapshots.length === 0 ? response : Promise.all(pendingSnapshots).then(() => ({ ...response, + _meta: getAgentHostExtensionInitializeResultMeta(client.canvases !== undefined, canRemoveSessionArtifact), + ...(client.canvases ? { canvases: {} } : {}), serverSeq: this._stateManager.serverSeq, snapshots: snapshots.map(snapshot => this._stateManager.getSnapshot(snapshot.resource) ?? snapshot), })).catch(error => { @@ -721,6 +749,12 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien * remain subscribed even when their snapshot has not materialized yet. */ private _addInitialSubscription(client: IConnectedClient, channel: string): IStateSnapshot | undefined | Promise { + if (URI.parse(channel).scheme === AHP_CANVAS_SCHEME && !isCanvasResource(channel)) { + return undefined; + } + if (isCanvasResource(channel) && !client.canvases) { + return undefined; + } const sub = classifyChannel(channel); if (!sub) { return undefined; @@ -734,7 +768,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return undefined; } // An annotation snapshot is synthetic until its persisted data has been loaded and ownership validated. - if (isAnnotationsUri(channel)) { + if (isAnnotationsUri(channel) || isCanvasResource(channel)) { return this._requestHandlers.subscribe(client, { channel }).then(result => result.snapshot).catch(error => { this._logService.info(`[ProtocolServer] Initialize: failed to restore subscription ${channel}: ${error instanceof Error ? error.message : String(error)}`); return undefined; @@ -785,7 +819,19 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien }); } - private async _subscribeStateChannel(channel: string, clientId: string, isActive?: () => boolean): Promise { + private async _subscribeStateChannel(channel: string, client: IConnectedClient, isActive?: () => boolean): Promise { + if (URI.parse(channel).scheme === AHP_CANVAS_SCHEME && !isCanvasResource(channel)) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'Invalid canvas channel.'); + } + const clientId = client.clientId; + if (isCanvasResource(channel)) { + const snapshot = this._canvasConnection(client).snapshot(channel); + if (isActive && !isActive()) { + throw new Error('Canvas subscription cancelled.'); + } + this._agentService.addSubscriber(URI.parse(channel), clientId); + return snapshot; + } if (!isAhpAutomationCatalogChannel(channel)) { return this._agentService.subscribe(URI.parse(channel), clientId, isActive); } @@ -850,6 +896,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien const priorProtocolVersion = existingRecord.state === 'active' ? existingRecord.connections.at(-1)?.protocolVersion : existingRecord.protocolVersion; + const canvasCapability = existingRecord.state === 'active' + ? existingRecord.connections.at(-1)?.canvases !== undefined : existingRecord.canvasCapability; + const canvasApproval = existingRecord.state === 'active' ? existingRecord.connections.at(-1)?.canvasApproval === true : existingRecord.canvasApproval; const isReconnect = this._clientConnections.hasSeenClient(params.clientId); const initializationDisposables = disposables.add(new DisposableStore()); const client: IConnectedClient = { @@ -864,6 +913,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien subscriptions: new Map(), disposables, initializationDisposables, + canvases: canvasCapability ? initializationDisposables.add(this._canvases.connect(params.clientId, (request, token) => this._requestCanvasApproval(client, request, token))) : undefined, + canvasApproval, }; this._attachConnection(params.clientId, client); try { @@ -991,7 +1042,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien try { const snapshot = await this._subscribeStateChannel( key, - client.clientId, + client, () => client.subscriptions.get(classified.uri) === pendingSubscription, ); if (client.subscriptions.get(classified.uri) !== pendingSubscription) { @@ -1290,6 +1341,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien clientInfo: undefined, telemetryContext: undefined, protocolVersion: undefined, + canvasCapability: false, + canvasApproval: false, lastSeenAt: Date.now(), disconnectTimeouts: new DisposableMap(), }; @@ -1371,6 +1424,30 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return result.trusted === true; } + async requestCanvasApproval(clientId: string, request: IAgentHostCanvasApprovalRequest, token: CancellationToken): Promise { + const client = this._getActiveClient(clientId); + return client ? this._requestCanvasApproval(client, request, token) : false; + } + + private async _requestCanvasApproval(client: IConnectedClient, request: IAgentHostCanvasApprovalRequest, token: CancellationToken): Promise { + if (!client.canvasApproval || token.isCancellationRequested || client.disposables.isDisposed) { + return false; + } + const cancellation = token.onCancellationRequested(() => { + client.transport.send({ jsonrpc: '2.0', method: CancelAgentHostCanvasApprovalExtensionMethod, params: { requestId: request.requestId } }); + }); + try { + const result = await this._sendReverseRequest(client.clientId, RequestAgentHostCanvasApprovalExtensionMethod, request, token, client); + return result?.requestId === request.requestId && result.approved === true && !token.isCancellationRequested && !client.disposables.isDisposed; + } finally { + cancellation.dispose(); + } + } + + getSubscribedClients(resource: string): readonly string[] { + return [...this._clients].flatMap(([clientId, record]) => record.state === 'active' && record.connections.some(connection => connection.subscriptions.has(resource)) ? [clientId] : []); + } + /** Number of clients that currently have a live connection. */ private get _connectedClientCount(): number { let count = 0; @@ -1531,7 +1608,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien try { const snapshot = await this._subscribeStateChannel( params.channel, - client.clientId, + client, () => client.subscriptions.get(classified.uri) === pendingSubscription, ); if (client.subscriptions.get(classified.uri) !== pendingSubscription) { @@ -1561,6 +1638,8 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien if (params.activeClient && params.activeClient.clientId !== _client.clientId) { throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `createSession.activeClient.clientId must match the connection's clientId`); } + const initialization = _client.canvases && !this._stateManager.getSessionState(params.channel) + ? _client.canvases.beginChatCreation(buildDefaultChatUri(params.channel)) : undefined; try { createdSession = await this._agentService.createSession({ provider: params.provider, @@ -1571,11 +1650,14 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien activeClient: params.activeClient, progressToken: params.progressToken, }); + initialization?.commit(); } catch (err) { if (err instanceof ProtocolError) { throw err; } throw new ProtocolError(AHP_PROVIDER_NOT_FOUND, err instanceof Error ? err.message : String(err)); + } finally { + initialization?.dispose(); } // Verify the provider honored the client-chosen session URI per the protocol contract if (createdSession.toString() !== URI.parse(params.channel).toString()) { @@ -1584,10 +1666,20 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return null; }, disposeSession: async (_client, params) => { + if (!isParamsObject(params) || typeof params.channel !== 'string' || !params.channel) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'channel must be a non-empty session URI string'); + } + for (const chat of this._stateManager.getSessionState(params.channel)?.chats ?? []) { + this._canvases.cancelChatInitialization(chat.resource); + } + this._canvases.cancelChatInitialization(buildDefaultChatUri(params.channel)); await this._agentService.disposeSession(URI.parse(params.channel)); return null; }, createChat: async (_client, params) => { + if (parseChatUri(params.chat)?.session !== params.channel) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'The new chat must belong to the exact creating session.'); + } const state = this._stateManager.getSessionState(params.channel); if (!state) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${params.channel}`); @@ -1618,14 +1710,22 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unsupported createChat source kind: ${String((source as { kind?: unknown }).kind)}`); } } - await this._agentService.createChat( - URI.parse(params.channel), - URI.parse(params.chat), - options, - ); + const initialization = _client.canvases && !state.chats.some(chat => chat.resource === params.chat) + ? _client.canvases.beginChatCreation(params.chat) : undefined; + try { + await this._agentService.createChat( + URI.parse(params.channel), + URI.parse(params.chat), + options, + ); + initialization?.commit(); + } finally { + initialization?.dispose(); + } return null; }, disposeChat: async (_client, params) => { + this._canvases.cancelChatInitialization(params.channel); const chat = URI.parse(params.channel); const parsed = parseChatUri(chat); if (!parsed) { @@ -1752,6 +1852,18 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien invokeChangesetOperation: async (_client, params) => { return this._agentService.invokeChangesetOperation(params); }, + listCanvasTypes: async (client, params) => this._canvasConnection(client).listCanvasTypes(params), + openCanvas: async (client, params) => this._canvasConnection(client).openCanvas(params), + resolveCanvasSource: async (client, params) => this._canvasConnection(client).resolveCanvasSource(params), + invokeCanvasAction: async (client, params) => this._canvasConnection(client).invokeCanvasAction(params), + restartCanvasProvider: async (client, params) => { + await this._canvasConnection(client).restartCanvasProvider(params); + return null; + }, + closeCanvas: async (client, params) => { + await this._canvasConnection(client).closeCanvas(params); + return null; + }, }; @@ -1766,14 +1878,26 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien * Used for reverse-RPC operations like reading client-side files. * Rejects if the client disconnects or the server is disposed. */ - private _sendReverseRequest(clientId: string, method: string, params: unknown): Promise { - const client = this._getActiveClient(clientId); - if (!client) { + private _sendReverseRequest(clientId: string, method: string, params: unknown, token?: CancellationToken, initiatingClient?: IConnectedClient): Promise { + const client = initiatingClient ?? this._getActiveClient(clientId); + const record = this._clients.get(clientId); + if (!client || token?.isCancellationRequested || record?.state !== 'active' || !record.connections.includes(client)) { return Promise.reject(new Error(`Client ${clientId} is not connected`)); } const id = ++this._reverseRequestId; return new Promise((resolve, reject) => { - this._pendingReverseRequests.set(id, { client, resolve: resolve as (value: unknown) => void, reject }); + const cancellation = token?.onCancellationRequested(() => { + const pending = this._pendingReverseRequests.get(id); + if (pending) { + this._pendingReverseRequests.delete(id); + pending.reject(new Error('Reverse request cancelled.')); + } + }); + this._pendingReverseRequests.set(id, { + client, + resolve: value => { cancellation?.dispose(); (resolve as (value: unknown) => void)(value); }, + reject: reason => { cancellation?.dispose(); reject(reason); }, + }); const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; client.transport.send(request); }); @@ -1799,6 +1923,11 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); client.transport.send(jsonRpcSuccess(id, result ?? null)); }).catch(err => { + if (isCanvasMethod(method)) { + this._logService.warn(`[ProtocolServer] Canvas request '${method}' failed.`); + client.transport.send(jsonRpcErrorFrom(id, err instanceof ProtocolError ? err : new ProtocolError(JSON_RPC_INTERNAL_ERROR, 'The canvas request failed without a confirmed result.'))); + return; + } if (shouldLogFailedRequest(method, params, err)) { this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); } @@ -1808,7 +1937,7 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } // VS Code extension methods (not in the typed protocol maps yet) - const extensionResult = this._handleExtensionRequest(method, params); + const extensionResult = this._handleExtensionRequest(client, method, params); if (extensionResult) { this._trackRequest(extensionResult).then(result => { client.transport.send(jsonRpcSuccess(id, result ?? null)); @@ -1861,7 +1990,22 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien * protocol. Returns a Promise if the method was recognized, undefined * otherwise. */ - private _handleExtensionRequest(method: string, params: unknown): Promise | undefined { + private _handleExtensionRequest(client: IConnectedClient, method: string, params: unknown): Promise | undefined { + // Initialization belongs to the negotiated canvas data plane even when + // host management uses a separate IPC channel. + if (method === InitializeCanvasChatExtensionMethod || method === CancelCanvasChatInitializationExtensionMethod) { + const validated = initializeCanvasChatParamsValidator.validate(params); + if (validated.error) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, validated.error.message)); + } + return Promise.resolve().then(() => { + const connection = this._canvasConnection(client); + return method === InitializeCanvasChatExtensionMethod + ? connection.initializeCanvasChat(validated.content) + : connection.cancelCanvasChatInitialization(validated.content); + }); + } + if (this._config.allowExtensionMethods === false) { return undefined; } @@ -2219,6 +2363,9 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien } private _isRelevantToClient(client: IConnectedClient, envelope: ActionEnvelope): boolean { + if (!client.canvases && (isCanvasResource(envelope.channel) || envelope.action.type === ActionType.SessionCanvasSet || envelope.action.type === ActionType.SessionCanvasRemoved)) { + return false; + } const sub = client.subscriptions.get(envelope.channel); if ((sub?.kind === ChannelKind.State || sub?.kind === ChannelKind.ResourceWatch) && sub.active) { return true; @@ -2226,6 +2373,13 @@ export class ProtocolServerHandler extends Disposable implements IAgentHostClien return isActionEnvelopeRelevantToSubscriptionUris(envelope, this._stateAndResourceWatchUris(client)); } + private _canvasConnection(client: IConnectedClient): IAgentHostCanvasConnection { + if (!client.canvases || client.initializationDisposables.isDisposed) { + throw new ProtocolError(JsonRpcErrorCodes.MethodNotFound, 'Canvas support was not negotiated on this connection.'); + } + return client.canvases; + } + private *_stateAndResourceWatchUris(client: IConnectedClient): Iterable { for (const sub of client.subscriptions.values()) { if ((sub.kind === ChannelKind.State || sub.kind === ChannelKind.ResourceWatch) && sub.active) { diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 9820648dec326..4c33b492e1d41 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -158,6 +158,13 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ SELECT '${AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY}', 'true' WHERE EXISTS (SELECT 1 FROM turn_workspace_transition)`, }, + { + version: 13, + sql: `CREATE TABLE IF NOT EXISTS turn_message_origin ( + turn_id TEXT PRIMARY KEY NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + origin TEXT NOT NULL + )`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -546,6 +553,29 @@ export class SessionDatabase implements ISessionDatabase { return this.setWorkspaceConversion(turnId, transition, {}); } + setTurnMessageOrigin(turnId: string, origin: string): Promise { + return this._mutateMetadataAndTurnUsage(async db => { + await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); + await dbRun(db, 'INSERT OR REPLACE INTO turn_message_origin (turn_id, origin) VALUES (?, ?)', [turnId, origin]); + }); + } + + getTurnMessageOrigins(): Promise> { + return this._metadataSequencer.queue(() => this._turnUsageSequencer.queue(() => this._queueOperation(async db => { + const rows = await dbAll(db, 'SELECT o.turn_id, t.event_id, o.origin FROM turn_message_origin o JOIN turns t ON t.id = o.turn_id', []); + const result = new Map(); + for (const row of rows) { + if (typeof row.origin === 'string' && typeof row.turn_id === 'string') { + result.set(row.turn_id, row.origin); + if (typeof row.event_id === 'string') { + result.set(row.event_id, row.origin); + } + } + } + return result; + }))); + } + setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise { return this._mutateMetadataAndTurnUsage(async db => { await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); @@ -973,6 +1003,7 @@ export class SessionDatabase implements ISessionDatabase { for (const [oldId, newId] of mapping) { await dbRun(db, 'UPDATE turn_usage SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); await dbRun(db, 'UPDATE turn_delegation SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE turn_message_origin SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); await dbRun(db, 'UPDATE turn_workspace_transition SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); } await this._deleteWorkspaceTransitionMarkerIfEmpty(db); diff --git a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts index d7a04c225b173..10bfddd561395 100644 --- a/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts +++ b/src/vs/platform/agentHost/node/shared/worktreeIsolation.ts @@ -51,6 +51,7 @@ export interface IAgentHostWorktreeIsolation extends IAgentHostWorktreePendingSt clearPending(sessionId: string): void; getResolvedWorktree(sessionId: string): URI | undefined; resolveOnFirstSend(request: IResolveWorkingDirectoryRequest): Promise; + resolveForInitialization(request: IResolveWorkingDirectoryRequest): Promise; createDetachedWorktree(request: Omit): Promise<{ handle: string; worktree: URI }>; claimDetachedWorktree(handle: string): Promise; setDetachedWorktreeArchived(handle: string, archived: boolean): Promise; @@ -501,6 +502,33 @@ export class WorktreeIsolation extends Disposable implements IAgentHostWorktreeI }); } + /** Prepares actual isolation before execution outside a turn; folder fallback and unfinished cleanup are not success. */ + async resolveForInitialization(request: IResolveWorkingDirectoryRequest): Promise { + return this._sequencer.queue(request.sessionId, async () => { + if (request.config?.[SessionConfigKey.Isolation] !== 'worktree' || this._worktreeDeletionRetries.has(request.sessionId)) { + throw new Error('The isolated worktree cannot be prepared while cleanup is pending.'); + } + const metadata = await this.readWorktreeMetadata(request.sessionUri); + if (metadata?.worktreePath && metadata.repositoryRoot) { + const resolved = await this._resolveWorkingDirectoryForResume(request.sessionUri, request.sessionId, metadata.worktreePath); + if (!isEqual(resolved, metadata.worktreePath) || isEqual(resolved, metadata.repositoryRoot)) { + throw new Error('Canvas initialization cannot fall back to the original repository.'); + } + this.clearPending(request.sessionId); + return resolved; + } + const resolved = await this.resolveWorkingDirectory(request); + const materialized = this._materializedWorktrees.get(request.sessionId); + const persisted = await this.readWorktreeMetadata(request.sessionUri); + if (!resolved || !materialized || !isEqual(materialized.worktree, resolved) || isEqual(resolved, materialized.repositoryRoot) + || !persisted?.worktreePath || !isEqual(resolved, persisted.worktreePath) || this._worktreeDeletionRetries.has(request.sessionId)) { + throw new Error('The isolated worktree was not prepared and persisted. Canvas initialization was not started.'); + } + this.clearPending(request.sessionId); + return resolved; + }); + } + async createDetachedWorktree(request: Omit): Promise<{ handle: string; worktree: URI }> { const handle = generateUuid(); const record = detachedWorktreeRecordUri(handle); @@ -1559,6 +1587,7 @@ export class NullAgentHostWorktreeIsolation implements IAgentHostWorktreeIsolati clearPending(_sessionId: string): void { } getResolvedWorktree(_sessionId: string): URI | undefined { return undefined; } async resolveOnFirstSend(_request: IResolveWorkingDirectoryRequest): Promise { return undefined; } + async resolveForInitialization(_request: IResolveWorkingDirectoryRequest): Promise { throw new Error('Worktree isolation is unavailable.'); } async createDetachedWorktree(_request: Omit): Promise<{ handle: string; worktree: URI }> { throw new Error('Worktree isolation is not supported.'); } async claimDetachedWorktree(_handle: string): Promise { } async setDetachedWorktreeArchived(_handle: string, _archived: boolean): Promise { } diff --git a/src/vs/platform/agentHost/test/common/agentHostCanvasesTestUtils.ts b/src/vs/platform/agentHost/test/common/agentHostCanvasesTestUtils.ts new file mode 100644 index 0000000000000..080822e2cfd75 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostCanvasesTestUtils.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; + +export const unavailableCanvases = { + _serviceBrand: undefined, + available: false, + readiness: undefined, + onDidReleaseHold: Event.None, + holdsSession: () => false, + connect: () => { throw new Error('No canvas runtime in this test.'); }, + loadChat: async () => [], + persistChat: async () => { }, + requestApproval: async () => false, + appendAttachments: () => { }, + discardPendingAttachments: () => { }, + getChatInitialization: () => undefined, + beginChatCreation: () => { throw new Error('No canvas initialization in this test.'); }, + isChatInitializing: () => false, + cancelChatInitialization: () => { }, + cancelSessionInitialization: () => { }, + assertChatInitialization: () => { }, + retainChat: async () => { }, + needsTurnInitialization: () => false, + prepareForTurn: async () => { }, + beginTurnPreparation: () => { throw new Error('No canvas turn preparation in this test.'); }, + cancelTurnPreparation: () => false, +}; diff --git a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts index b105454c3987d..2bdf6e33bf250 100644 --- a/src/vs/platform/agentHost/test/common/agentSubscription.test.ts +++ b/src/vs/platform/agentHost/test/common/agentSubscription.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; @@ -14,6 +15,8 @@ import { AutomationOperation, AutomationRunOriginKind, AutomationRunStatus, Chan import { AUTOMATION_CATALOG_URI, buildDefaultChatUri, createChatState, createDefaultChatSummary, getTurnError, ROOT_STATE_URI, StateComponents, type ChatState } from '../../common/state/sessionState.js'; import { AgentSubscriptionManager, AutomationCatalogSubscription, AutomationRunSubscription, ChangesetStateSubscription, ChatStateSubscription, isActionEnvelopeRelevantToSubscriptionUris, RootStateSubscription, SessionStateSubscription, TerminalStateSubscription } from '../../common/state/agentSubscription.js'; import { normalizeLegacyActionEnvelope, readLegacyTurnError } from '../../common/state/legacyProtocolCompatibility.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasEntry, type CanvasState } from '../../common/state/protocol/channels-canvas/state.js'; +import { AhpErrorCodes, ProtocolError, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; // Helpers @@ -889,7 +892,7 @@ suite('AgentSubscriptionManager', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createManager(subscribe: (resource: URI) => Promise<{ resource: string; state: SessionState | TerminalState | ChangesetState | AnnotationsState | AutomationState; fromSeq: number }> = async resource => { + function createManager(subscribe: (resource: URI) => Promise = async resource => { const key = resource.toString(); subscribedResources.push(key); if (key.endsWith('/annotations')) { @@ -1281,6 +1284,197 @@ suite('AgentSubscriptionManager', () => { ref.dispose(); }); + suite('cold canvas subscriptions', () => { + const canvas: CanvasState = { + resource: 'ahp-canvas:/cold-triage', + identity: { + chat: chatUri, source: { kind: CanvasSourceKind.Extension, extensionId: 'project:triage' }, + canvasType: 'triage', instanceId: 'board', incarnation: 'cold', + }, + title: 'Triage', trust: { status: CanvasTrustStatus.Pending }, + availability: { status: CanvasAvailabilityStatus.NotLoaded }, revision: 6, + }; + const membership: CanvasEntry = { ...canvas, availability: canvas.availability.status }; + + test('an initial missing canvas subscription recovers when its exact owner snapshot arrives', async () => { + const restored = new DeferredPromise(); + const missing = new ProtocolError(AhpErrorCodes.NotFound, 'Canvas membership was not found.'); + let canvasReads = 0; + const mgr = createManager(async resource => { + subscribedResources.push(resource.toString()); + if (resource.toString() === sessionUri) { + return restored.p; + } + if (++canvasReads === 1) { + throw missing; + } + return { resource: canvas.resource, state: canvas, fromSeq: 6 }; + }); + disposables.add(mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'Owner')); + const ref = disposables.add(mgr.getSubscription(StateComponents.Canvas, URI.parse(canvas.resource), 'Editor')); + await timeout(0); + const originalError = ref.object.value; + await restored.complete({ + resource: sessionUri, state: makeSessionState(sessionUri, { canvases: [membership] }), fromSeq: 5, + }); + await timeout(0); + const cold = ref.object.value; + const availability: CanvasState['availability'] = { status: CanvasAvailabilityStatus.Ready, actions: [] }; + mgr.receiveEnvelope(makeEnvelope({ + type: ActionType.CanvasAvailabilityChanged, availability, revision: 7, + }, 7, undefined, undefined, canvas.resource)); + assert.deepStrictEqual({ + originalErrorPreserved: originalError === missing, subscribedResources, cold, current: ref.object.value, + }, { + originalErrorPreserved: true, subscribedResources: [sessionUri, canvas.resource, canvas.resource], + cold: canvas, current: { ...canvas, availability, revision: 7 }, + }); + }); + + test('the owner snapshot may arrive before the initial NotFound response', async () => { + const initial = new DeferredPromise(); + let canvasReads = 0; + const mgr = createManager(async resource => { + if (resource.toString() === sessionUri) { + return { resource: sessionUri, state: makeSessionState(sessionUri, { canvases: [membership] }), fromSeq: 5 }; + } + return ++canvasReads === 1 ? initial.p : { resource: canvas.resource, state: canvas, fromSeq: 6 }; + }); + disposables.add(mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'Owner')); + const ref = disposables.add(mgr.getSubscription(StateComponents.Canvas, URI.parse(canvas.resource), 'Editor')); + await timeout(0); + await initial.error(new ProtocolError(AhpErrorCodes.NotFound, 'Canvas membership was not found.')); + await timeout(0); + assert.deepStrictEqual({ canvasReads, state: ref.object.value }, { canvasReads: 2, state: canvas }); + }); + + test('an authoritative membership action can recover an initial missing canvas', async () => { + let canvasReads = 0; + const mgr = createManager(async resource => { + if (resource.toString() === sessionUri) { + return { resource: sessionUri, state: makeSessionState(sessionUri), fromSeq: 5 }; + } + if (++canvasReads === 1) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'Canvas membership was not found.'); + } + return { resource: canvas.resource, state: canvas, fromSeq: 6 }; + }); + disposables.add(mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'Owner')); + const ref = disposables.add(mgr.getSubscription(StateComponents.Canvas, URI.parse(canvas.resource), 'Editor')); + await timeout(0); + mgr.receiveEnvelope(makeEnvelope({ type: ActionType.SessionCanvasSet, canvas: membership }, 6)); + await timeout(0); + assert.deepStrictEqual({ canvasReads, state: ref.object.value }, { canvasReads: 2, state: canvas }); + }); + + for (const error of [new Error('Original transport failure'), new ProtocolError(AhpErrorCodes.PermissionDenied, 'Original permission denial')]) { + test(`membership does not retry ${error.message}`, async () => { + let canvasReads = 0; + const mgr = createManager(async resource => { + if (resource.toString() === sessionUri) { + return { resource: sessionUri, state: makeSessionState(sessionUri, { canvases: [membership] }), fromSeq: 5 }; + } + canvasReads++; + throw error; + }); + disposables.add(mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'Owner')); + const ref = disposables.add(mgr.getSubscription(StateComponents.Canvas, URI.parse(canvas.resource), 'Editor')); + await timeout(0); + mgr.receiveEnvelope(makeEnvelope({ type: ActionType.SessionCanvasSet, canvas: membership }, 6)); + await timeout(0); + assert.deepStrictEqual({ canvasReads, originalErrorPreserved: ref.object.value === error }, { canvasReads: 1, originalErrorPreserved: true }); + }); + } + + for (const announced of [ + { ...membership, resource: 'ahp-canvas:/different-resource' }, + { ...membership, identity: { ...membership.identity, chat: buildDefaultChatUri('copilot:/different-owner') } }, + ]) { + test(`membership must match the subscribed resource and owner: ${announced.resource}, ${announced.identity.chat}`, async () => { + const missing = new ProtocolError(AhpErrorCodes.NotFound, 'Original missing membership'); + let canvasReads = 0; + const mgr = createManager(async resource => { + if (resource.toString() === sessionUri) { + return { resource: sessionUri, state: makeSessionState(sessionUri, { canvases: [announced] }), fromSeq: 5 }; + } + canvasReads++; + throw missing; + }); + disposables.add(mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'Owner')); + const ref = disposables.add(mgr.getSubscription(StateComponents.Canvas, URI.parse(canvas.resource), 'Editor')); + await timeout(0); + assert.deepStrictEqual({ canvasReads, originalErrorPreserved: ref.object.value === missing }, { canvasReads: 1, originalErrorPreserved: true }); + }); + } + + test('recovery is shared by holders and bounded to one read per confirmed incarnation', async () => { + const recovery = new DeferredPromise(); + const initialError = new ProtocolError(AhpErrorCodes.NotFound, 'Original missing membership'); + const recoveryError = new ProtocolError(AhpErrorCodes.NotFound, 'Original recovery failure'); + let canvasReads = 0; + const mgr = createManager(async resource => { + if (resource.toString() === sessionUri) { + return { resource: sessionUri, state: makeSessionState(sessionUri, { canvases: [membership] }), fromSeq: 5 }; + } + if (++canvasReads === 1) { + throw initialError; + } + if (canvasReads === 2) { + return recovery.p; + } + throw recoveryError; + }); + disposables.add(mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'Owner')); + const uri = URI.parse(canvas.resource); + const ref = disposables.add(mgr.getSubscription(StateComponents.Canvas, uri, 'Editor')); + await timeout(0); + const other = disposables.add(mgr.getSubscription(StateComponents.Canvas, uri, 'Other holder')); + const duringRecovery = { reads: canvasReads, sameSubscription: other.object === ref.object, originalError: ref.object.value === initialError }; + mgr.receiveEnvelope(makeEnvelope({ type: ActionType.SessionCanvasSet, canvas: membership }, 6)); + await recovery.error(recoveryError); + await timeout(0); + mgr.receiveEnvelope(makeEnvelope({ type: ActionType.SessionCanvasSet, canvas: { ...membership, revision: 7 } }, 7)); + await timeout(0); + const afterFailure = { reads: canvasReads, originalError: ref.object.value === recoveryError }; + mgr.receiveEnvelope(makeEnvelope({ + type: ActionType.SessionCanvasSet, canvas: { ...membership, identity: { ...membership.identity, incarnation: 'next' }, revision: 8 }, + }, 8)); + await timeout(0); + assert.deepStrictEqual({ duringRecovery, afterFailure, finalReads: canvasReads, finalErrorPreserved: ref.object.value === recoveryError }, { + duringRecovery: { reads: 2, sameSubscription: true, originalError: true }, + afterFailure: { reads: 2, originalError: true }, finalReads: 3, finalErrorPreserved: true, + }); + }); + + test('a disposed recovery cannot overwrite a replacement subscription', async () => { + const recovery = new DeferredPromise(); + let canvasReads = 0; + const replacement: CanvasState = { ...canvas, identity: { ...canvas.identity, incarnation: 'replacement' }, revision: 8 }; + const mgr = createManager(async resource => { + if (resource.toString() === sessionUri) { + return { resource: sessionUri, state: makeSessionState(sessionUri, { canvases: [membership] }), fromSeq: 5 }; + } + if (++canvasReads === 1) { + throw new ProtocolError(AhpErrorCodes.NotFound, 'Canvas membership was not found.'); + } + return canvasReads === 2 ? recovery.p : { resource: canvas.resource, state: replacement, fromSeq: 8 }; + }); + disposables.add(mgr.getSubscription(StateComponents.Session, URI.parse(sessionUri), 'Owner')); + const uri = URI.parse(canvas.resource); + const old = disposables.add(mgr.getSubscription(StateComponents.Canvas, uri, 'Old editor')); + await timeout(0); + old.dispose(); + const current = disposables.add(mgr.getSubscription(StateComponents.Canvas, uri, 'New editor')); + await timeout(0); + await recovery.complete({ resource: canvas.resource, state: canvas, fromSeq: 6 }); + await timeout(0); + old.dispose(); + assert.deepStrictEqual({ canvasReads, state: current.object.value, unsubscribedResources }, { + canvasReads: 3, state: replacement, unsubscribedResources: [canvas.resource], + }); + }); + }); + suite('ordinary optimistic reconnect state', () => { test('applyReconnectSnapshot clears pending actions and applies the fresh state', async () => { diff --git a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts index 79ee5a79249fc..84acc60ee1670 100644 --- a/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts +++ b/src/vs/platform/agentHost/test/common/ahpJsonlLogger.test.ts @@ -17,6 +17,26 @@ suite('AhpJsonlLogger', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + test('canvas presentation credentials are never written, even when the request was not logged', 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: 'canvas', transport: 'message_port' }, + fileService, new NullLogService(), + )); + const source = { url: 'http://127.0.0.1:8123/app?credential=preview-secret', expiresAt: '2026-01-01T00:00:00Z' }; + const response = { jsonrpc: '2.0', id: 42, result: { availability: 'ready', incarnation: 'instance', revision: 1, source } }; + logger.log(response, 's2c'); + logger.log({ ...response, padding: 'x'.repeat(1024 * 1024) }, 's2c'); + await logger.flush(); + const content = (await fileService.readFile(logger.resource)).value.toString(); + assert.deepStrictEqual({ + sources: content.trim().split('\n').map(line => JSON.parse(line).result.source), + credentialsPersisted: content.includes('preview-secret'), + wireSourceUnchanged: response.result.source === source && response.result.source.url.endsWith('preview-secret'), + }, { sources: [{ redacted: true }, { redacted: true }], credentialsPersisted: false, wireSourceUnchanged: true }); + }); + test('writes canonical JSON-RPC JSONL with metadata at the root', async () => { const fileService = store.add(new FileService(new NullLogService())); store.add(fileService.registerProvider('file', store.add(new InMemoryFileSystemProvider()))); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index 2990970847a43..aba0e820a7bb7 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -21,6 +21,7 @@ export class TestSessionDatabase implements ISessionDatabase { private readonly _localTurns = new Map(); private readonly _turnUsages = new Map(); private readonly _turnDelegations = new Map(); + private readonly _turnMessageOrigins = new Map(); private readonly _turnWorkspaceTransitions = new Map(); private readonly _turnEventIds = new Map(); @@ -40,6 +41,7 @@ export class TestSessionDatabase implements ISessionDatabase { async deleteTurn(turnId: string): Promise { this._turnDelegations.delete(turnId); + this._turnMessageOrigins.delete(turnId); this._turnWorkspaceTransitions.delete(turnId); this._turnEventIds.delete(turnId); for (let i = this._edits.length - 1; i >= 0; i--) { @@ -180,6 +182,21 @@ export class TestSessionDatabase implements ISessionDatabase { this._metadata.set(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY, 'true'); } + async setTurnMessageOrigin(turnId: string, origin: string): Promise { + this._turnMessageOrigins.set(turnId, origin); + } + + async getTurnMessageOrigins(): Promise> { + const result = new Map(this._turnMessageOrigins); + for (const [turnId, eventId] of this._turnEventIds) { + const origin = this._turnMessageOrigins.get(turnId); + if (origin) { + result.set(eventId, origin); + } + } + return result; + } + async setWorkspaceConversion(turnId: string, transition: string, metadata: Readonly>): Promise { for (const [key, value] of Object.entries(metadata)) { this._metadata.set(key, value); @@ -215,6 +232,7 @@ export class TestSessionDatabase implements ISessionDatabase { this.deleteAllTurnsCalls++; this._edits.length = 0; this._turnDelegations.clear(); + this._turnMessageOrigins.clear(); this._turnWorkspaceTransitions.clear(); this._metadata.delete(AH_META_HAS_WORKSPACE_TRANSITIONS_DB_KEY); this._turnEventIds.clear(); @@ -234,6 +252,11 @@ export class TestSessionDatabase implements ISessionDatabase { } } async remapTurnIds(mapping: ReadonlyMap, eventIds?: ReadonlyMap): Promise { + for (const turnId of [...this._turnMessageOrigins.keys()]) { + if (!mapping.has(turnId)) { + this._turnMessageOrigins.delete(turnId); + } + } for (const turnId of [...this._turnDelegations.keys()]) { if (!mapping.has(turnId)) { this._turnDelegations.delete(turnId); @@ -245,6 +268,11 @@ export class TestSessionDatabase implements ISessionDatabase { } } for (const [oldId, newId] of mapping) { + const origin = this._turnMessageOrigins.get(oldId); + if (origin) { + this._turnMessageOrigins.delete(oldId); + this._turnMessageOrigins.set(newId, origin); + } const delegation = this._turnDelegations.get(oldId); if (delegation) { this._turnDelegations.delete(oldId); diff --git a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts index ffdd813ae47fc..8cda72e5d5fdc 100644 --- a/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/agentHostProtocolClient.test.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { IDialogService, type IConfirmation } from '../../../dialogs/common/dialogs.js'; import sinon from 'sinon'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; @@ -18,7 +20,9 @@ import { mock } from '../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, AgentHostProtocolClient } from '../../browser/agentHostProtocolClient.js'; -import { getAgentHostExtensionInitializeResultMeta, RequestAgentHostWorkspaceTrustExtensionMethod } from '../../common/agentHostExtensionProtocol.js'; +import { CancelAgentHostCanvasApprovalExtensionMethod, CancelCanvasChatInitializationExtensionMethod, getAgentHostExtensionInitializeResultMeta, InitializeCanvasChatExtensionMethod, RequestAgentHostCanvasApprovalExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod } from '../../common/agentHostExtensionProtocol.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasEntry, type CanvasState } from '../../common/state/protocol/channels-canvas/state.js'; +import type { OpenCanvasParams } from '../../common/state/protocol/channels-canvas/commands.js'; import { agentHostAuthority, toAgentHostUri } from '../../common/agentHostUri.js'; import { AgentHostPermissionMode, AgentHostResourceIdentity, AgentHostResourcePermissionError, IAgentHostResourceService, LOCAL_AGENT_HOST_RESOURCE_IDENTITY } from '../../common/agentHostResourceService.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; @@ -82,6 +86,7 @@ const syncTestConfigurationNode = { }, }; import type { Implementation } from '../../common/state/protocol/common/commands.js'; +import { SessionLifecycle, type SessionState } from '../../common/state/protocol/channels-session/state.js'; import { agentsWindowAgentHostClientInfo, editorWindowAgentHostClientInfo } from '../../common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../common/agentHostTelemetry.js'; import type { IRemoteAgentHostReconnectPolicy } from '../../common/reconnectPolicy.js'; @@ -205,6 +210,10 @@ class TestProtocolTransport extends Disposable implements IProtocolTransport { this._onMessage.fire({ jsonrpc: '2.0', id, method, params } as unknown as ProtocolMessage); } + fireExtensionNotification(method: string, params: Record): void { + this._onMessage.fire({ jsonrpc: '2.0', method, params } as unknown as ProtocolMessage); + } + fireClose(): void { this._onClose.fire(); } @@ -281,6 +290,14 @@ class ManagedPermissionsConfigurationService extends TestConfigurationService { suite('AgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const canvasDialogs = new class extends mock() { + readonly requests: IConfirmation[] = []; + response: Promise<{ confirmed: boolean }> | undefined; + override async confirm(confirmation: IConfirmation): Promise<{ confirmed: boolean }> { + this.requests.push(confirmation); + return this.response ?? { confirmed: false }; + } + }(); const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); suiteSetup(() => configurationRegistry.registerConfiguration(syncTestConfigurationNode)); @@ -387,7 +404,7 @@ suite('AgentHostProtocolClient', () => { const options = loadEstimator !== undefined || clientId !== undefined || clientInfo !== undefined || reconnectPolicy !== undefined ? { loadEstimator, clientId, clientInfo, reconnectPolicy } : undefined; - const client = disposables.add(new AgentHostProtocolClient(identity, transport, options, logService, permissionService, configurationService, telemetryService, workspaceTrustEnablementService, workspaceTrust.management, workspaceTrust.request)); + const client = disposables.add(new AgentHostProtocolClient(identity, transport, options, logService, permissionService, configurationService, telemetryService, workspaceTrustEnablementService, workspaceTrust.management, workspaceTrust.request, canvasDialogs)); return { client, transport, configurationService }; } @@ -395,7 +412,7 @@ suite('AgentHostProtocolClient', () => { return createClientForIdentity('test.example:1234', transport, permissionService, loadEstimator, logService, configurationService, clientId, clientInfo); } - async function connectClient(client: AgentHostProtocolClient, transport: TestProtocolTransport, meta?: Record): Promise { + async function connectClient(client: AgentHostProtocolClient, transport: TestProtocolTransport, meta?: Record, canvases = false): Promise { const connectPromise = client.connect(); while (transport.sentMessages.length === 0) { await Promise.resolve(); @@ -404,7 +421,7 @@ suite('AgentHostProtocolClient', () => { transport.fireMessage({ jsonrpc: '2.0', id: sent.id, - result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], _meta: meta }, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 0, snapshots: [], _meta: meta, ...(canvases ? { canvases: {} } : {}) }, }); await connectPromise; } @@ -420,7 +437,7 @@ suite('AgentHostProtocolClient', () => { URI.parse('vscode-remote://ssh-remote+test/ssh/trusted'), URI.parse('vscode-remote://ssh-remote+other/other/trusted'), ]; - const client = disposables.add(new AgentHostProtocolClient(identity, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request)); + const client = disposables.add(new AgentHostProtocolClient(identity, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request, canvasDialogs)); await connectClient(client, transport); const path = identity === LOCAL_AGENT_HOST_RESOURCE_IDENTITY ? '/local/trusted' : identity === 'test.example:1234' ? '/remote/trusted' : '/ssh/trusted'; assert.deepStrictEqual(findRootConfigValue(transport.sentMessages, AgentHostWorkspaceTrustConfigKey), { enabled: true, trustedUris: [URI.file(path).toString()] }); @@ -432,7 +449,7 @@ suite('AgentHostProtocolClient', () => { const trustService = new TestWorkspaceTrustManagementService(); const changed = disposables.add(new Emitter()); trustService.onDidChangeTrustedFolders = changed.event; - const client = disposables.add(new AgentHostProtocolClient(LOCAL_AGENT_HOST_RESOURCE_IDENTITY, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService({ 'security.workspace.trust.enabled': false }), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request)); + const client = disposables.add(new AgentHostProtocolClient(LOCAL_AGENT_HOST_RESOURCE_IDENTITY, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService({ 'security.workspace.trust.enabled': false }), NullTelemetryService, workspaceTrustEnablementService, trustService, createWorkspaceTrustServices().request, canvasDialogs)); await connectClient(client, transport); const states = [findRootConfigValue(transport.sentMessages, AgentHostWorkspaceTrustConfigKey)]; for (const trustedUris of [[URI.file('/repo')], []]) { @@ -450,7 +467,7 @@ suite('AgentHostProtocolClient', () => { test('workspace trust forwards explicit disablement from the enablement service', async () => { const transport = disposables.add(new TestProtocolTransport()); - const client = disposables.add(new AgentHostProtocolClient(LOCAL_AGENT_HOST_RESOURCE_IDENTITY, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, { _serviceBrand: undefined, isWorkspaceTrustEnabled: () => false }, new TestWorkspaceTrustManagementService(), createWorkspaceTrustServices().request)); + const client = disposables.add(new AgentHostProtocolClient(LOCAL_AGENT_HOST_RESOURCE_IDENTITY, transport, undefined, new NullLogService(), createPermissionService(), new TestConfigurationService(), NullTelemetryService, { _serviceBrand: undefined, isWorkspaceTrustEnabled: () => false }, new TestWorkspaceTrustManagementService(), createWorkspaceTrustServices().request, canvasDialogs)); await connectClient(client, transport); assert.deepStrictEqual(findRootConfigValue(transport.sentMessages, AgentHostWorkspaceTrustConfigKey), { enabled: false, trustedUris: [] }); }); @@ -497,6 +514,170 @@ suite('AgentHostProtocolClient', () => { } } + suite('canvas commands and consent', () => { + const owner = 'copilot:/canvas-client'; + const chat = buildChatUri(owner, 'default'); + const canvas: CanvasEntry = { + resource: 'ahp-canvas:/client', identity: { chat, source: { kind: CanvasSourceKind.Extension, extensionId: 'project:counter' }, canvasType: 'counter', instanceId: 'main', incarnation: 'first' }, + title: 'Counter', trust: { status: CanvasTrustStatus.Trusted }, availability: CanvasAvailabilityStatus.Ready, revision: 1, + }; + const openParams = { channel: owner, identity: canvas.identity, canvas: canvas.resource, title: canvas.title, requestId: 'open' }; + + setup(() => { + canvasDialogs.requests.length = 0; + canvasDialogs.response = undefined; + }); + + test('all six public methods send their canonical routes only after capability negotiation', async () => { + const { client, transport } = createClient(); + await connectClient(client, transport, undefined, true); + const cases = [ + { method: 'listCanvasTypes', run: () => client.listCanvasTypes({ channel: chat }), result: { types: [] } }, + { method: 'openCanvas', run: () => client.openCanvas(openParams), result: { canvas } }, + { method: 'resolveCanvasSource', run: () => client.resolveCanvasSource({ channel: canvas.resource }), result: { availability: 'ready', incarnation: 'first', revision: 1, source: { url: 'http://127.0.0.1:8000/app' } } }, + { method: 'invokeCanvasAction', run: () => client.invokeCanvasAction({ channel: canvas.resource, actionId: 'increment', incarnation: 'first', requestId: 'action' }), result: { result: { count: 1 } } }, + { method: 'restartCanvasProvider', run: () => client.restartCanvasProvider({ channel: canvas.resource, incarnation: 'first', requestId: 'restart' }), result: null }, + { method: 'closeCanvas', run: () => client.closeCanvas({ channel: canvas.resource, revision: 1, requestId: 'close' }), result: null }, + ]; + for (const entry of cases) { + const response = entry.run(); + const request = transport.sentMessages.at(-1); + assert.ok(request && hasKey(request, { id: true, method: true })); + assert.strictEqual(request.method, entry.method); + transport.fireMessage({ jsonrpc: '2.0', id: request.id, result: entry.result }); + assert.deepStrictEqual(await response, entry.result === null ? undefined : entry.result); + } + const initialize = transport.sentMessages[0] as JsonRpcRequest; + assert.deepStrictEqual((initialize.params as { capabilities: { canvases: object } }).capabilities.canvases, {}); + }); + + test('cold canvas recovery only resubscribes the exact state channel after its owner is restored', async () => { + const { client, transport } = createClientForIdentity(LOCAL_AGENT_HOST_RESOURCE_IDENTITY); + await connectClient(client, transport, undefined, true); + const start = transport.sentMessages.length; + const requests = () => transport.sentMessages.slice(start).filter((message): message is JsonRpcRequest => hasKey(message, { id: true, method: true })); + disposables.add(client.getSubscription(StateComponents.Session, URI.parse(owner), 'Owner')); + const ref = disposables.add(client.getSubscription(StateComponents.Canvas, URI.parse(canvas.resource), 'Editor')); + const observed: CanvasState[] = []; + disposables.add(ref.object.onDidChange(state => observed.push(state))); + await timeout(0); + const [ownerRead, initialRead] = requests(); + assert.ok(ownerRead && initialRead); + transport.fireMessage({ jsonrpc: '2.0', id: initialRead.id, error: { code: AhpErrorCodes.NotFound, message: 'Canvas membership was not found.' } }); + await timeout(0); + const originalError = ref.object.value; + assert.ok(originalError instanceof ProtocolError); + const cold: CanvasState = { ...canvas, trust: { status: CanvasTrustStatus.Pending }, availability: { status: CanvasAvailabilityStatus.NotLoaded } }; + const ownerState: SessionState = { + provider: 'copilot', title: 'Cold owner', status: SessionStatus.Idle, lifecycle: SessionLifecycle.Ready, activeClients: [], chats: [], + canvases: [{ ...cold, availability: cold.availability.status }], + }; + transport.fireMessage({ jsonrpc: '2.0', id: ownerRead.id, result: { snapshot: { resource: owner, state: ownerState, fromSeq: 5 } } }); + await timeout(0); + const retry = requests()[2]; + assert.ok(retry, 'The held failed canvas subscription must get a fresh state read after owner hydration.'); + const originalErrorWhileReading = ref.object.value === originalError; + transport.fireMessage({ jsonrpc: '2.0', id: retry.id, result: { snapshot: { resource: canvas.resource, state: cold, fromSeq: 6 } } }); + await timeout(0); + assert.deepStrictEqual({ + errorCode: originalError.code, originalErrorWhileReading, state: ref.object.value, observed, + requests: requests().map(request => ({ method: request.method, params: request.params })), dialogs: canvasDialogs.requests, + }, { + errorCode: AhpErrorCodes.NotFound, originalErrorWhileReading: true, state: cold, observed: [cold], + requests: [ + { method: 'subscribe', params: { channel: owner } }, + { method: 'subscribe', params: { channel: canvas.resource } }, + { method: 'subscribe', params: { channel: canvas.resource } }, + ], + dialogs: [], + }); + }); + + test('absent capability and a closed connection never queue a canvas request', async () => { + const { client, transport } = createClient(); + await connectClient(client, transport); + const before = transport.sentMessages.length; + await assert.rejects(client.openCanvas(openParams), /not negotiated/); + await assert.rejects(client.listCanvasTypes({ channel: chat }), /not negotiated/); + transport.fireClose(); + await assert.rejects(client.openCanvas(openParams), /never queued/); + assert.strictEqual(transport.sentMessages.length, before); + }); + + test('explicit initialization requires its own capability and cancellation reuses the exact chat and request ID', async () => { + const unsupported = createClient(); + await connectClient(unsupported.client, unsupported.transport, undefined, true); + const before = unsupported.transport.sentMessages.length; + await assert.rejects(unsupported.client.initializeCanvasChat({ channel: chat, requestId: 'unsupported' }), /not negotiated/); + assert.strictEqual(unsupported.transport.sentMessages.length, before); + + const { client, transport } = createClient(); + await connectClient(client, transport, getAgentHostExtensionInitializeResultMeta(true), true); + const cancellation = disposables.add(new CancellationTokenSource()); + const params = { channel: chat, requestId: 'original' }; + const result = client.initializeCanvasChat(params, cancellation.token); + const initialize = transport.sentMessages.at(-1); + assert.ok(initialize && hasKey(initialize, { id: true, method: true })); + cancellation.cancel(); + const cancel = transport.sentMessages.at(-1); + assert.ok(cancel && hasKey(cancel, { id: true, method: true })); + assert.deepStrictEqual([initialize.method, initialize.params, cancel.method, cancel.params], [ + InitializeCanvasChatExtensionMethod, params, CancelCanvasChatInitializationExtensionMethod, params, + ]); + transport.fireMessage({ jsonrpc: '2.0', id: cancel.id, result: null }); + transport.fireMessage({ jsonrpc: '2.0', id: initialize.id, result: null }); + await result; + }); + + test('a synchronous send/close race settles the existing request instead of leaking a rejected deferred', async () => { + const transport = disposables.add(new class extends TestProtocolTransport { + override send(message: ProtocolTransportMessage): void { + if (hasKey(message, { method: true }) && message.method === 'openCanvas') { + this.fireClose(); + throw new Error('Socket closed during send'); + } + super.send(message); + } + }()); + const { client } = createClient(transport); + await connectClient(client, transport, undefined, true); + await assert.rejects(client.openCanvas(openParams)); + await flushMicrotasks(); + }); + + test('a real out-of-turn dialog is custom, cancellable, and late acceptance is denied', async () => { + const { client, transport } = createClient(); + await connectClient(client, transport, undefined, true); + const answered = new DeferredPromise<{ confirmed: boolean }>(); + canvasDialogs.response = answered.p; + transport.fireExtensionRequest(800, RequestAgentHostCanvasApprovalExtensionMethod, { requestId: 'nonce', chat, message: 'Allow this exact mutable source?' }); + await flushMicrotasks(); + const dialog = canvasDialogs.requests[0]; + assert.ok(dialog.custom && dialog.token); + assert.ok(typeof dialog.detail === 'string'); + assert.match(dialog.detail, /not a Workspace Trust grant/); + transport.fireExtensionNotification(CancelAgentHostCanvasApprovalExtensionMethod, { requestId: 'nonce' }); + assert.strictEqual(dialog.token.isCancellationRequested, true); + await answered.complete({ confirmed: true }); + await flushMicrotasks(); + assert.deepStrictEqual(transport.sentMessages.find(message => hasKey(message, { id: true, result: true }) && message.id === 800), { jsonrpc: '2.0', id: 800, result: { requestId: 'nonce', approved: false } }); + }); + + test('malformed and oversized consent requests never open a dialog', async () => { + const { client, transport } = createClient(); + await connectClient(client, transport, undefined, true); + for (const [index, params] of [ + { requestId: '', chat, message: 'Invalid nonce' }, + { requestId: 'nonce', chat: owner, message: 'Not a chat' }, + { requestId: 'nonce', chat, message: 'x'.repeat(16_385) }, + ].entries()) { + transport.fireExtensionRequest(900 + index, RequestAgentHostCanvasApprovalExtensionMethod, params); + } + await flushMicrotasks(); + assert.deepStrictEqual(canvasDialogs.requests, []); + }); + }); + function fireConfigurationChange(configurationService: TestConfigurationService, settingId: string, source = ConfigurationTarget.USER): void { configurationService.onDidChangeConfigurationEmitter.fire({ source, @@ -1290,6 +1471,7 @@ suite('AgentHostProtocolClient', () => { workspaceTrustEnablementService, workspaceTrust.management, workspaceTrust.request, + canvasDialogs, )); const connectPromise = client.connect(); @@ -2567,12 +2749,12 @@ suite('AgentHostProtocolClient', () => { }; const workspaceTrust = createWorkspaceTrustServices(); const client = disposables.add(new AgentHostProtocolClient( - 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined || loadEstimator !== undefined ? { clientInfo, reconnectPolicy, loadEstimator } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, workspaceTrustEnablementService, workspaceTrust.management, workspaceTrust.request, + 'test.example:1234', factory, clientInfo !== undefined || reconnectPolicy !== undefined || loadEstimator !== undefined ? { clientInfo, reconnectPolicy, loadEstimator } : undefined, new NullLogService(), permissionService, new TestConfigurationService(), telemetryService, workspaceTrustEnablementService, workspaceTrust.management, workspaceTrust.request, canvasDialogs, )); return { client, transports }; } - async function completeHandshake(transport: TestClientProtocolTransport, connectPromise: Promise): Promise { + async function completeHandshake(transport: TestClientProtocolTransport, connectPromise: Promise, canvases = false): Promise { transport.connectDeferred.complete(); while (findRequest(transport, 'initialize') === undefined) { await Promise.resolve(); @@ -2580,11 +2762,29 @@ suite('AgentHostProtocolClient', () => { const init = findRequest(transport, 'initialize')!; transport.fireMessage({ jsonrpc: '2.0', id: init.id, - result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 5, snapshots: [] }, + result: { protocolVersion: PROTOCOL_VERSION, serverSeq: 5, snapshots: [], ...(canvases ? { canvases: {} } : {}) }, }); await connectPromise; } + test('canvas requests are neither parked nor replayed across reconnect', async () => { + const { client, transports } = createFactoryClient(); + const connecting = client.connect(); + await completeHandshake(transports[0], connecting, true); + const params: OpenCanvasParams = { channel: 'copilot:/canvas', canvas: 'ahp-canvas:/canvas', title: 'Counter', requestId: 'open', identity: { chat: buildChatUri('copilot:/canvas', 'default'), source: { kind: CanvasSourceKind.Extension, extensionId: 'project:counter' }, canvasType: 'counter', instanceId: 'main' } }; + const pending = assert.rejects(client.openCanvas(params)); + transports[0].fireClose(); + await pending; + await waitForReconnecting(client); + await assert.rejects(client.openCanvas(params), /never queued/); + const next = await waitForTransport(transports, 1); + next.connectDeferred.complete(); + const reconnect = await waitForRequest(next, 'reconnect'); + next.fireMessage({ jsonrpc: '2.0', id: reconnect.id, result: { type: ReconnectResultType.Replay, actions: [], missing: [] } }); + await waitForConnectedWithin(client); + assert.strictEqual(findRequest(next, 'openCanvas'), undefined); + }); + test('retries an initial transport failure with a fresh initialization', async function () { this.timeout(10_000); const { client, transports } = createFactoryClient(); diff --git a/src/vs/platform/agentHost/test/node/agentHostCanvasTestUtils.ts b/src/vs/platform/agentHost/test/node/agentHostCanvasTestUtils.ts new file mode 100644 index 0000000000000..537cb98e3a9fc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCanvasTestUtils.ts @@ -0,0 +1,130 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IAgentCanvasInstance, IAgentCanvasOperation, IAgentCanvases, IAgentCanvasSnapshot } from '../../common/agentHostCanvases.js'; +import type { OpenCanvasParams } from '../../common/state/protocol/channels-canvas/commands.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasIdentityKey, type CanvasSourcePresentation, type CanvasState, type CanvasTrustState } from '../../common/state/protocol/channels-canvas/state.js'; +import { buildDefaultChatUri, SessionStatus } from '../../common/state/sessionState.js'; +import { AgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; +import { AgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; +import { AgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; +import { AgentHostProviderService } from '../../node/agentHostProviderService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { validateCanvasInput } from '../../node/agentHostCanvasSchema.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +import { MockAgent } from './mockAgent.js'; +import { NullAgentHostWorktreeIsolation, type IAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; + +export const canvasSession = 'copilot:/canvas-test'; +export const canvasChat = buildDefaultChatUri(canvasSession); +export const canvasIdentity: CanvasIdentityKey = { + chat: canvasChat, source: { kind: CanvasSourceKind.Extension, extensionId: 'project:counter' }, canvasType: 'counter', instanceId: 'main', +}; + +export class TestCanvases extends Disposable implements IAgentCanvases { + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + available = true; + initialized = true; + defersHostTurnStart = false; + onInitialize?: (chat: string, operation: IAgentCanvasOperation) => Promise; + instanceIdScope: 'chat' | undefined; + readiness: Promise | undefined; + trust: CanvasTrustState = { status: CanvasTrustStatus.Trusted }; + readonly calls: string[] = []; + resolveResult: CanvasSourcePresentation = { url: 'http://127.0.0.1:8123/canvas?ephemeral=not-persisted' }; + resolveGate?: Promise; + invokeResult: unknown = { count: 1 }; + invokeGate?: Promise; + beforeValidate?: () => void; + schemaReference: unknown; + snapshot: IAgentCanvasSnapshot = { + chat: canvasChat, generation: 'first', + types: [{ source: canvasIdentity.source, canvasType: 'counter', title: 'Counter', declaredActions: [{ id: 'preview-only' }] }], + instances: [], + }; + + getSnapshot(chat: string): IAgentCanvasSnapshot | undefined { return this.initialized && this.snapshot.chat === chat ? this.snapshot : undefined; } + getTrust(): CanvasTrustState { return this.trust; } + async prepare(): Promise { this.calls.push('prepare'); } + async initializeChat(chat: string, operation: IAgentCanvasOperation): Promise { + this.calls.push('initialize'); + if (!this.initialized) { + operation.willExecute(); + await this.onInitialize?.(chat, operation); + operation.willExecute(); + this.initialized = true; + } + } + publish(snapshot: IAgentCanvasSnapshot): void { + this.snapshot = snapshot; + this._onDidChange.fire(snapshot); + } + instance(identity = canvasIdentity): IAgentCanvasInstance { + return { identity, title: 'Counter', availability: { status: CanvasAvailabilityStatus.Ready, actions: [{ id: 'increment' }] } }; + } + async open(params: OpenCanvasParams, operation: IAgentCanvasOperation): Promise { + operation.willExecute(); + this.calls.push('open'); + const instance = this.instance(params.identity); + this.publish({ ...this.snapshot, closed: [], instances: [instance] }); + return instance; + } + async invoke(_state: CanvasState, _params: object, operation: IAgentCanvasOperation): Promise { + operation.willExecute(); + this.calls.push('invoke'); + await this.invokeGate; + return this.invokeResult; + } + async close(state: CanvasState, operation: IAgentCanvasOperation): Promise { + operation.willExecute(); + this.calls.push('close'); + this.publish({ ...this.snapshot, closed: [state.identity], instances: [] }); + } + async restart(_state: CanvasState, operation: IAgentCanvasOperation): Promise { + operation.willExecute(); + this.calls.push('restart'); + this.publish({ ...this.snapshot, generation: `${this.snapshot.generation}-next` }); + } + async resolve(_state: CanvasState, clientId: string): Promise { + this.calls.push(`resolve:${clientId}`); + return this.resolveGate ?? this.resolveResult; + } + async resolveSchema(): Promise { return this.schemaReference; } + async validateInput(_chat: string, _source: object, schema: object, input: unknown): Promise { + this.beforeValidate?.(); + validateCanvasInput(schema, input); + } +} + +class CanvasAgent extends MockAgent { + constructor(readonly canvases: TestCanvases) { super('copilot'); } +} + +export function createCanvasServices(store: Pick, state = store.add(new AgentHostStateManager(new NullLogService())), connections = store.add(new AgentHostClientConnectionService()), worktree: IAgentHostWorktreeIsolation = new NullAgentHostWorktreeIsolation()) { + const services = createCanvasHostServices(store, state, connections, worktree); + const facet = store.add(new TestCanvases()); + services.providers.registerProvider(new CanvasAgent(facet)); + return { ...services, facet }; +} + +export function createCanvasHostServices(store: Pick, state = store.add(new AgentHostStateManager(new NullLogService())), connections = store.add(new AgentHostClientConnectionService()), worktree: IAgentHostWorktreeIsolation = new NullAgentHostWorktreeIsolation()) { + const database = new TestSessionDatabase(); + const authentication = store.add(new AgentHostAuthenticationService(new NullLogService())); + const providers = store.add(new AgentHostProviderService(authentication, new NullLogService())); + const service = store.add(new AgentHostCanvasesService(providers, state, createSessionDataService(database), new NullLogService(), connections, worktree, authentication, createTestGitHubEndpointService())); + return { state, database, connections, providers, service }; +} + +export function createCanvasSession(state: AgentHostStateManager): void { + state.createSession({ + resource: canvasSession, provider: 'copilot', title: 'Canvas Test', status: SessionStatus.Idle, + createdAt: '2026-01-01T00:00:00.000Z', modifiedAt: '2026-01-01T00:00:00.000Z', + }); +} diff --git a/src/vs/platform/agentHost/test/node/agentHostCanvases.test.ts b/src/vs/platform/agentHost/test/node/agentHostCanvases.test.ts new file mode 100644 index 0000000000000..f568ce29dfd6f --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostCanvases.test.ts @@ -0,0 +1,730 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { isBoundedCanvasJson, isInlineCanvasSchema, validateCanvasActions, validateCanvasRequest } from '../../common/agentHostCanvasValidation.js'; +import { CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN, type IAgentCanvasInstance } from '../../common/agentHostCanvases.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasIdentityKey } from '../../common/state/protocol/channels-canvas/state.js'; +import type { IAgentHostChatContributionContext } from '../../common/agentHostChatContributionsService.js'; +import type { OpenCanvasParams } from '../../common/state/protocol/channels-canvas/commands.js'; +import { AhpErrorCodes, JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { ActionType, isCanvasAction } from '../../common/state/sessionActions.js'; +import { buildChatUri, MessageAttachmentKind, MessageKind, TurnState, type Turn } from '../../common/state/sessionState.js'; +import { CanvasStateSubscription } from '../../common/state/agentSubscription.js'; +import { AgentHostCanvasApproval } from '../../node/agentHostCanvasApproval.js'; +import { AgentHostCanvasOperationLedger, CanvasOperationIndeterminateError } from '../../node/agentHostCanvasOperationLedger.js'; +import { validateCanvasInput } from '../../node/agentHostCanvasSchema.js'; +import { CanvasesContribution } from '../../node/chatContributions/canvases/canvasesContribution.js'; +import { createSessionDataService } from '../common/sessionTestHelpers.js'; +import { canvasChat, canvasIdentity, canvasSession, createCanvasServices, createCanvasSession } from './agentHostCanvasTestUtils.js'; +import { isCanvasSessionRetained } from '../../common/meta/agentCanvasSessionMeta.js'; + +const openParams: OpenCanvasParams = { channel: canvasSession, canvas: 'ahp-canvas:/test', identity: canvasIdentity, title: 'Counter', requestId: 'open' }; +const invalidParams = (error: unknown) => error instanceof ProtocolError && error.code === JsonRpcErrorCodes.InvalidParams; +const conflict = (error: unknown) => error instanceof ProtocolError && error.code === AhpErrorCodes.Conflict; +const denied = (error: unknown) => error instanceof ProtocolError && error.code === AhpErrorCodes.PermissionDenied; + +suite('Agent Host canvas validation', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('JSON bounds reject getters, cycles, holes, non-JSON values and oversized UTF-16', () => { + let getterRead = false; + const cycle: { child?: object } = {}; + cycle.child = cycle; + const values: unknown[] = [undefined, NaN, Infinity, BigInt(1), () => { }, new Date(), new Array(1), cycle, + Object.defineProperty({}, 'value', { enumerable: true, get: () => { getterRead = true; return 1; } }), '😀'.repeat(32768)]; + assert.deepStrictEqual({ accepted: values.map(value => isBoundedCanvasJson(value)), getterRead }, { accepted: values.map(() => false), getterRead: false }); + assert.strictEqual(isBoundedCanvasJson({ value: 'a'.repeat(65524) }), true); + }); + + test('inline schemas enforce property, action, combinator and reference depth bounds', () => { + const properties = Object.fromEntries(Array.from({ length: 64 }, (_, index) => [`p${index}`, { type: 'string' }])); + let schema: object = { type: 'object' }; + for (let index = 0; index < 4; index++) { + schema = { type: 'object', allOf: [schema] }; + } + assert.deepStrictEqual([ + isInlineCanvasSchema({ type: 'object', properties }), + isInlineCanvasSchema({ type: 'object', properties: { ...properties, extra: {} } }), + isInlineCanvasSchema(schema), + isInlineCanvasSchema({ type: 'object', $ref: '#/$defs/cycle', $defs: { cycle: { $ref: '#/$defs/cycle' } } }), + ], [true, false, false, false]); + assert.throws(() => validateCanvasActions(Array.from({ length: 65 }, (_, index) => ({ id: `${index}` }))), invalidParams); + assert.throws(() => validateCanvasActions([{ id: 'a' }, { id: 'a' }]), invalidParams); + }); + + test('commands bind exact owning chat and reject oversized or malformed parameters', () => { + assert.throws(() => validateCanvasRequest('openCanvas', { ...openParams, channel: 'copilot:/other' }), invalidParams); + assert.throws(() => validateCanvasRequest('openCanvas', { ...openParams, identity: { ...canvasIdentity, chat: 'not a chat' } }), invalidParams); + assert.throws(() => validateCanvasRequest('openCanvas', { ...openParams, requestId: 'x'.repeat(257) }), invalidParams); + assert.throws(() => validateCanvasRequest('closeCanvas', { channel: openParams.canvas, revision: Infinity, requestId: 'close' }), invalidParams); + assert.throws(() => validateCanvasRequest('invokeCanvasAction', { channel: openParams.canvas, actionId: 'a', incarnation: 'i', requestId: 'action', input: { value: undefined } }), invalidParams); + }); + + test('schema validation is non-transforming and supports Unicode, tuples, refs and exact decimals', () => { + const input = { word: '😀', amount: 0.3, tuple: ['x', 2], requiredOnly: true }; + validateCanvasInput({ + type: 'object', additionalProperties: true, required: ['word', 'amount', 'requiredOnly'], + $defs: { word: { type: 'string', minLength: 1, maxLength: 1 } }, + properties: { + word: { $ref: '#/$defs/word' }, amount: { type: 'number', multipleOf: 0.1 }, + tuple: { type: 'array', prefixItems: [{ type: 'string' }, { type: 'integer' }], items: false }, + notSupplied: { type: 'string', default: 'not inserted' }, + }, + }, input); + assert.deepStrictEqual(input, { word: '😀', amount: 0.3, tuple: ['x', 2], requiredOnly: true }); + }); + + test('schema assertions are conjoined and malformed or unsupported schemas fail explicitly', () => { + for (const [schema, input] of [ + [{ enum: [1, 2], const: 3 }, 1], + [{ type: 'number', multipleOf: 0.1 }, 0.31], + [{ type: 'number', minimum: 'bad' }, 4], + [{ type: 'number', multipleOf: 0 }, 4], + [{ type: 'string', pattern: '.*' }, 'x'], + [{ $ref: 'https://untrusted.example/schema' }, {}], + [{ $defs: { node: { $ref: '#/$defs/node' } }, $ref: '#/$defs/node' }, {}], + [{ type: 'object', required: ['missing'], properties: { missing: { default: 'not admitted', type: 'string' } } }, {}], + [{ oneOf: [{ type: 'number' }, { minimum: 1 }] }, 4], + ] as const) { + assert.throws(() => validateCanvasInput(schema, input), invalidParams); + } + }); +}); + +suite('Agent Host canvas retry ledger', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('same request bytes deduplicate success and failure; new IDs execute again', async () => { + const ledger = store.add(new AgentHostCanvasOperationLedger()); + let calls = 0; + const gate = new DeferredPromise(); + const first = ledger.execute('one', { input: 1 }, async operation => { operation.willExecute(); calls++; return gate.p; }); + const retry = ledger.execute('one', { input: 1 }, async () => { calls++; return 2; }); + assert.strictEqual(first, retry); + assert.throws(() => ledger.execute('one', { input: 2 }, async () => 0), conflict); + await gate.complete(1); + const next = await ledger.execute('two', { input: 1 }, async () => ++calls); + assert.deepStrictEqual([await first, await retry, calls, next], [1, 1, 2, 2]); + }); + + test('capacity never evicts pending requests, expiry permits new work', async () => { + let now = 0; + const ledger = store.add(new AgentHostCanvasOperationLedger(1, 10, () => now)); + const gate = new DeferredPromise(); + const first = ledger.execute('one', {}, async () => gate.p); + now = 100; + assert.throws(() => ledger.execute('two', {}, async () => 2), conflict); + await gate.complete(1); + await first; + now += 10; + assert.strictEqual(await ledger.execute('two', {}, async () => 2), 2); + }); + + test('timeout and disconnect are indeterminate after dispatch, never automatic retries', async () => { + await runWithFakedTimers({}, async () => { + const ledger = store.add(new AgentHostCanvasOperationLedger(2, 100, Date.now, 10)); + let calls = 0; + const run = () => ledger.execute('one', {}, async operation => { + operation.willExecute(); + calls++; + await new Promise(() => { }); + }); + await assert.rejects(run(), CanvasOperationIndeterminateError); + await assert.rejects(run(), CanvasOperationIndeterminateError); + assert.strictEqual(calls, 1); + }); + const ledger = store.add(new AgentHostCanvasOperationLedger()); + const pending = ledger.execute('late', {}, async operation => { operation.willExecute(); await new Promise(() => { }); }); + const result = assert.rejects(pending, CanvasOperationIndeterminateError); + ledger.dispose(); + await result; + }); +}); + +suite('Agent Host canvas initialization', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('identity-free initialization deduplicates effects and retains an empty session without a turn', async () => { + const f = createCanvasServices(store); + createCanvasSession(f.state); + f.facet.initialized = false; + const entered = new DeferredPromise(); + const release = new DeferredPromise(); + let launches = 0; + f.facet.onInitialize = async (chat, operation) => { + assert.strictEqual(operation.clientId, 'owner'); + launches++; + await f.service.retainChat(chat, operation.token); + await entered.complete(); + await release.p; + }; + const connection = store.add(f.service.connect('owner')); + const params = { channel: canvasChat, requestId: 'initialize' }; + assert.deepStrictEqual(await connection.listCanvasTypes({ channel: canvasChat }), { types: [] }); + const first = connection.initializeCanvasChat(params); + const retry = connection.initializeCanvasChat(params); + await entered.p; + assert.strictEqual(f.service.holdsSession(canvasSession), true); + await release.complete(); + await Promise.all([first, retry]); + await connection.initializeCanvasChat({ ...params, requestId: 'ensure-again' }); + connection.dispose(); + const state = f.state.getSessionState(canvasSession)!; + assert.deepStrictEqual({ + launches, retained: isCanvasSessionRetained(state), unused: f.state.isUnusedDraft(canvasSession), + active: state.activeTurn, turns: state.turns, members: f.state.getChatCanvasStates(canvasChat), held: f.service.holdsSession(canvasSession), + reloaded: await f.service.loadChat(canvasChat), + }, { launches: 1, retained: true, unused: false, active: undefined, turns: [], members: [], held: false, reloaded: [] }); + }); + + test('cancellation remains indeterminate after initialization starts and never retries the same request', async () => { + const f = createCanvasServices(store); + createCanvasSession(f.state); + f.facet.initialized = false; + const entered = new DeferredPromise(); + const release = new DeferredPromise(); + f.facet.onInitialize = async () => { await entered.complete(); await release.p; }; + const connection = store.add(f.service.connect('owner')); + const params = { channel: canvasChat, requestId: 'cancel' }; + const first = connection.initializeCanvasChat(params); + await entered.p; + assert.throws(() => connection.cancelCanvasChatInitialization({ ...params, channel: buildChatUri(canvasSession, 'wrong-chat') }), conflict); + assert.strictEqual(f.service.getChatInitialization(canvasChat)?.token.isCancellationRequested, false); + const rejected = assert.rejects(first, CanvasOperationIndeterminateError); + connection.cancelCanvasChatInitialization(params); + await rejected; + await assert.rejects(connection.initializeCanvasChat(params), CanvasOperationIndeterminateError); + await release.complete(); + await timeout(0); + assert.deepStrictEqual({ + calls: f.facet.calls, snapshot: f.facet.getSnapshot(canvasChat), + initializing: f.service.isChatInitializing(canvasChat), held: f.service.holdsSession(canvasSession), + turns: f.state.getChatState(canvasChat)?.turns, members: f.state.getChatCanvasStates(canvasChat), + }, { calls: ['initialize'], snapshot: undefined, initializing: false, held: false, turns: [], members: [] }); + }); + + for (const cancellation of ['dispose', 'disconnect', 'delete'] as const) { + test(`pending-chat ${cancellation} invalidates its exact creator and late callbacks`, async () => { + const f = createCanvasServices(store); + const connection = store.add(f.service.connect('owner')); + const lease = store.add(connection.beginChatCreation(canvasChat)); + const generation = f.state.getChatGeneration(canvasChat); + f.facet.publish({ ...f.facet.snapshot, instances: [f.facet.instance()] }); + assert.deepStrictEqual([f.state.getSnapshot(canvasChat), f.state.getSessionState(canvasSession)], [undefined, undefined]); + if (cancellation === 'dispose') { + lease.dispose(); + } else if (cancellation === 'disconnect') { + connection.dispose(); + } else { + f.service.cancelSessionInitialization(canvasSession); + } + assert.throws(() => lease.willExecute(), /Canceled/); + lease.dispose(); + f.facet.publish({ ...f.facet.snapshot, instances: [f.facet.instance()] }); + createCanvasSession(f.state); + await timeout(0); + assert.deepStrictEqual({ + replaced: generation !== f.state.getChatGeneration(canvasChat), + members: f.state.getChatCanvasStates(canvasChat), held: f.service.holdsSession(canvasSession), + }, { replaced: true, members: [], held: false }); + }); + } + + test('pending peer registration transfers its exact state generation without advertising a ready chat first', async () => { + const f = createCanvasServices(store); + createCanvasSession(f.state); + const peer = buildChatUri(canvasSession, 'pending-peer'); + const connection = store.add(f.service.connect('creator')); + const lease = store.add(connection.beginChatCreation(peer)); + const generation = f.state.getChatGeneration(peer); + f.state.dispatchServerAction(peer, { + type: ActionType.ChatTurnStarted, turnId: 'native-message', startedAt: '2026-01-01T00:00:00Z', + message: { text: 'Genuine native input', origin: { kind: MessageKind.Tool } }, + }); + assert.strictEqual(f.state.getSessionState(canvasSession)?.chats.some(chat => chat.resource === peer), false); + f.state.addChat(canvasSession, peer); + lease.commit(); + lease.dispose(); + assert.deepStrictEqual({ + generationPreserved: f.state.getChatGeneration(peer) === generation, + active: f.state.getSnapshot(peer)?.state, + }, { generationPreserved: true, active: f.state.getChatState(peer) }); + assert.strictEqual(f.state.getActiveTurnId(peer), 'native-message'); + }); + + test('a late initializer cannot commit onto a replacement chat with the same URI', async () => { + const f = createCanvasServices(store); + createCanvasSession(f.state); + const peer = buildChatUri(canvasSession, 'peer'); + f.state.addChat(canvasSession, peer); + const lease = store.add(f.service.beginChatCreation(peer)); + f.state.removeChat(canvasSession, peer); + f.state.addChat(canvasSession, peer); + assert.throws(() => lease.commit(), /Canceled/); + assert.throws(() => lease.willExecute(), /Canceled/); + }); + + test('lazy peer initialization ingests pre-return events and merges restored history without duplicates', async () => { + const f = createCanvasServices(store); + createCanvasSession(f.state); + const peer = buildChatUri(canvasSession, 'restored-peer'); + let historyReads = 0; + const native: Turn = { id: 'native', state: TurnState.Complete, message: { text: 'Native', origin: { kind: MessageKind.Tool } }, responseParts: [], usage: undefined }; + f.state.registerRestoredChatSummary(canvasSession, peer, { + draft: { text: 'Preserved draft', origin: { kind: MessageKind.User } }, + resolver: async () => { + historyReads++; + return { turns: [{ ...native, id: 'old', message: { text: 'Old', origin: { kind: MessageKind.User } } }, native] }; + }, + }); + const generation = f.state.getChatGeneration(peer); + f.facet.snapshot = { ...f.facet.snapshot, chat: peer }; + f.facet.initialized = false; + f.facet.onInitialize = async chat => { + assert.strictEqual(f.state.getSnapshot(chat), undefined); + f.state.dispatchServerAction(chat, { type: ActionType.ChatTurnStarted, turnId: native.id, startedAt: '2026-01-01T00:00:00Z', message: native.message }); + f.state.dispatchServerAction(chat, { type: ActionType.ChatTurnComplete, turnId: native.id, duration: 0 }); + }; + const connection = store.add(f.service.connect('owner')); + assert.deepStrictEqual([await connection.listCanvasTypes({ channel: peer }), historyReads], [{ types: [] }, 0]); + await connection.initializeCanvasChat({ channel: peer, requestId: 'initialize' }); + assert.deepStrictEqual({ + ids: f.state.getChatState(peer)?.turns.map(turn => turn.id), historyReads, + draft: f.state.getChatState(peer)?.draft?.text, generation: f.state.getChatGeneration(peer), + }, { ids: ['old', 'native'], historyReads: 1, draft: 'Preserved draft', generation }); + }); +}); + +suite('Agent Host canvas coordinator', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let fixture: ReturnType; + + setup(() => { + fixture = createCanvasServices(store); + createCanvasSession(fixture.state); + }); + + test('catalogue browsing and subscriptions never prepare or admit providers', async () => { + const connection = store.add(fixture.service.connect('client')); + assert.strictEqual((await connection.listCanvasTypes({ channel: canvasChat })).types.length, 1); + assert.throws(() => connection.snapshot(openParams.canvas)); + assert.deepStrictEqual({ calls: fixture.facet.calls, members: fixture.state.getChatCanvasStates(canvasChat) }, { calls: [], members: [] }); + }); + + test('open and native observation publish one membership, retries do not repeat open', async () => { + const connection = store.add(fixture.service.connect('client')); + const first = await connection.openCanvas(openParams); + const retry = await connection.openCanvas(openParams); + await connection.openCanvas({ ...openParams, requestId: 'new-id', canvas: 'ahp-canvas:/ignored' }); + await timeout(0); + assert.deepStrictEqual({ + sameResult: first.canvas.resource === retry.canvas.resource, + resources: fixture.state.getChatCanvasStates(canvasChat).map(state => state.resource), + opens: fixture.facet.calls.filter(call => call === 'open').length, + draft: fixture.state.isUnusedDraft(canvasSession), + }, { sameResult: true, resources: [openParams.canvas], opens: 2, draft: false }); + }); + + test('native-open ingestion is already executed, not a second provider open', async () => { + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [fixture.facet.instance()] }); + await timeout(0); + assert.deepStrictEqual({ + members: fixture.state.getChatCanvasStates(canvasChat).map(state => state.identity.instanceId), + calls: fixture.facet.calls, + }, { members: ['main'], calls: [] }); + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [], closed: [canvasIdentity] }); + await timeout(0); + assert.deepStrictEqual(fixture.state.getChatCanvasStates(canvasChat), []); + }); + + suite('authoritative icon metadata', () => { + const original: NonNullable = { src: 'file:///canvas-icons/original.png', sizes: ['16x16'], theme: 'dark' }; + const replacement: NonNullable = { src: 'file:///canvas-icons/replacement.png', contentType: 'image/png' }; + for (const change of [ + { name: 'sets an absent icon', before: undefined, after: original, title: 'Counter', revisions: 1 }, + { name: 'changes the icon', before: original, after: replacement, title: 'Counter', revisions: 1 }, + { name: 'changes icon metadata without changing its source', before: original, after: { ...original, sizes: ['32x32'] }, title: 'Counter', revisions: 1 }, + { name: 'clears the icon', before: original, after: undefined, title: 'Counter', revisions: 1 }, + { name: 'preserves equal metadata without a spurious action', before: original, after: { ...original, sizes: ['16x16'] }, title: 'Counter', revisions: 0 }, + { name: 'preserves absent metadata without a spurious action', before: undefined, after: undefined, title: 'Counter', revisions: 0 }, + { name: 'changes title and icon with distinct fresh revisions', before: original, after: replacement, title: 'Renamed Counter', revisions: 2 }, + ]) { + test(change.name, async () => { + fixture.facet.publish({ + ...fixture.facet.snapshot, instances: [{ ...fixture.facet.instance(), ...(change.before ? { icon: change.before } : {}) }], + }); + await timeout(0); + const initial = fixture.state.getChatCanvasStates(canvasChat)[0]; + assert.ok(initial); + const subscription = store.add(new CanvasStateSubscription(initial.resource, 'metadata-reader', () => { })); + subscription.handleSnapshot(initial, fixture.state.serverSeq); + const revisions: number[] = []; + store.add(fixture.state.onDidEmitEnvelope(envelope => { + subscription.receiveEnvelope(envelope); + if (envelope.channel === initial.resource && isCanvasAction(envelope.action)) { + revisions.push(envelope.action.revision); + } + })); + fixture.facet.publish({ + ...fixture.facet.snapshot, + instances: [{ ...fixture.facet.instance(), title: change.title, ...(change.after ? { icon: change.after } : {}) }], + }); + await timeout(0); + const current = fixture.state.getCanvasState(initial.resource); + const entry = fixture.state.getSessionState(canvasSession)?.canvases?.find(canvas => canvas.resource === initial.resource); + const states = [current, entry, subscription.verifiedValue].map(state => ({ + title: state?.title, icon: state?.icon, hasIcon: state && Object.hasOwn(state, 'icon'), revision: state?.revision, + })); + const expected = { title: change.title, icon: change.after, hasIcon: change.after !== undefined, revision: initial.revision + change.revisions }; + assert.deepStrictEqual({ + states, revisions, identity: current?.identity, providerCalls: fixture.facet.calls, + }, { + states: [expected, expected, expected], + revisions: Array.from({ length: change.revisions }, (_, index) => initial.revision + index + 1), + identity: initial.identity, providerCalls: [], + }); + }); + } + + test('icon snapshots do not share mutable provider data', async () => { + const icon = { ...original, sizes: ['16x16'] }; + const instance = { ...fixture.facet.instance(), icon }; + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [instance] }); + await timeout(0); + const first = fixture.state.getChatCanvasStates(canvasChat)[0]; + icon.sizes[0] = '32x32'; + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [instance] }); + await timeout(0); + const second = fixture.state.getChatCanvasStates(canvasChat)[0]; + icon.sizes[0] = '64x64'; + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [instance] }); + await timeout(0); + const third = fixture.state.getChatCanvasStates(canvasChat)[0]; + assert.deepStrictEqual({ + sizes: [first, second, third].map(state => state.icon?.sizes), + revisions: [first, second, third].map(state => state.revision), + providerCalls: fixture.facet.calls, + }, { sizes: [['16x16'], ['32x32'], ['64x64']], revisions: [1, 2, 3], providerCalls: [] }); + }); + + test('cleared icons stay absent after metadata-only restoration', async () => { + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [{ ...fixture.facet.instance(), icon: original }] }); + await timeout(0); + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [fixture.facet.instance()] }); + await timeout(0); + const restored = await fixture.service.loadChat(canvasChat); + const entry = fixture.state.getSessionState(canvasSession)?.canvases?.[0]; + assert.deepStrictEqual({ + restored: restored.map(state => ({ icon: state.icon, hasIcon: Object.hasOwn(state, 'icon') })), + projectedIcon: entry?.icon, projectedHasIcon: entry && Object.hasOwn(entry, 'icon'), providerCalls: fixture.facet.calls, + }, { restored: [{ icon: undefined, hasIcon: false }], projectedIcon: undefined, projectedHasIcon: false, providerCalls: [] }); + }); + }); + + test('canonical identity remains source-qualified; native chat-wide namespaces are opt-in', async () => { + const other: CanvasIdentityKey = { ...canvasIdentity, source: { kind: CanvasSourceKind.Extension, extensionId: 'user:other' } }; + fixture.facet.publish({ + ...fixture.facet.snapshot, + types: [...fixture.facet.snapshot.types, { source: other.source, canvasType: other.canvasType, title: 'Other' }], + instances: [fixture.facet.instance(), fixture.facet.instance(other)], + }); + await timeout(0); + assert.deepStrictEqual([fixture.state.getChatCanvasStates(canvasChat).length, (await fixture.service.loadChat(canvasChat)).length], [2, 2]); + fixture.facet.instanceIdScope = 'chat'; + const connection = store.add(fixture.service.connect('client')); + await assert.rejects(connection.openCanvas(openParams), conflict); + assert.deepStrictEqual(fixture.facet.calls, []); + }); + + test('an explicit native close frees its ID even when a replacement snapshot coalesces the events', async () => { + fixture.facet.instanceIdScope = 'chat'; + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [fixture.facet.instance()] }); + await timeout(0); + const replacement = { ...canvasIdentity, canvasType: 'replacement' }; + fixture.facet.publish({ + ...fixture.facet.snapshot, closed: [canvasIdentity], + types: [{ source: replacement.source, canvasType: replacement.canvasType, title: 'Replacement' }], + instances: [fixture.facet.instance(replacement)], + }); + await timeout(0); + assert.deepStrictEqual(fixture.state.getChatCanvasStates(canvasChat).map(state => state.identity.canvasType), ['replacement']); + }); + + test('membership limits are checked for the whole observation before any new entry is installed', async () => { + fixture.facet.publish({ ...fixture.facet.snapshot, instances: Array.from({ length: 63 }, (_, index) => fixture.facet.instance({ ...canvasIdentity, instanceId: `existing-${index}` })) }); + await timeout(0); + fixture.facet.publish({ ...fixture.facet.snapshot, instances: ['new-one', 'new-two'].map(instanceId => fixture.facet.instance({ ...canvasIdentity, instanceId })) }); + await timeout(0); + const states = fixture.state.getChatCanvasStates(canvasChat); + assert.deepStrictEqual([states.length, states.some(state => state.identity.instanceId.startsWith('new-')), states.every(state => state.availability.status === CanvasAvailabilityStatus.Failed)], [63, false, true]); + }); + + test('logical close cannot be undone by a still-live, untrusted snapshot', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + fixture.facet.trust = { status: CanvasTrustStatus.Pending }; + await connection.closeCanvas({ channel: canvas.resource, revision: canvas.revision, requestId: 'close' }); + fixture.facet.trust = { status: CanvasTrustStatus.Trusted }; + fixture.facet.publish({ ...fixture.facet.snapshot }); + await timeout(0); + assert.deepStrictEqual(fixture.state.getChatCanvasStates(canvasChat), []); + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [{ ...fixture.facet.instance(), generation: 'replacement' }] }); + await timeout(0); + assert.deepStrictEqual([fixture.state.getChatCanvasStates(canvasChat).length, fixture.facet.calls], [1, ['prepare', 'open']]); + }); + + test('durable state remains browsable and logically closable after its provider is removed', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + fixture.providers.dispose(); + const current = fixture.state.getCanvasState(canvas.resource)!; + assert.deepStrictEqual({ + status: current.availability.status, + snapshot: connection.snapshot(canvas.resource).resource, + types: (await connection.listCanvasTypes({ channel: canvasChat })).types, + source: (await connection.resolveCanvasSource({ channel: canvas.resource })).source, + }, { status: CanvasAvailabilityStatus.NotLoaded, snapshot: canvas.resource, types: [], source: undefined }); + await connection.closeCanvas({ channel: canvas.resource, revision: current.revision, requestId: 'close' }); + assert.deepStrictEqual(await fixture.service.loadChat(canvasChat), []); + }); + + test('early attachments wait for their exact chat and can be discarded with a failed backing', async () => { + const peer = buildChatUri(canvasSession, 'pending-peer'); + const attachment = { type: MessageAttachmentKind.Simple, label: 'Native', modelRepresentation: 'Captured state' } as const; + fixture.service.appendAttachments(peer, [attachment]); + assert.strictEqual(fixture.state.getChatState(canvasChat)?.draft, undefined); + fixture.state.addChat(canvasSession, peer, { title: 'Peer' }); + assert.deepStrictEqual(fixture.state.getChatState(peer)?.draft?.attachments, [attachment]); + const discarded = buildChatUri(canvasSession, 'discarded-peer'); + fixture.service.appendAttachments(discarded, [attachment]); + fixture.service.discardPendingAttachments(discarded); + fixture.state.addChat(canvasSession, discarded, { title: 'Discarded' }); + assert.deepStrictEqual([fixture.state.getChatState(discarded)?.draft, fixture.facet.calls], [undefined, []]); + }); + + test('early observations expire rather than resurrecting an abandoned chat', async () => { + await runWithFakedTimers({}, async () => { + for (const expired of [false, true]) { + const early = createCanvasServices(store); + const initialization = store.add(early.service.beginChatCreation(canvasChat)); + early.facet.publish({ ...early.facet.snapshot, instances: [early.facet.instance()] }); + if (expired) { + await timeout(120_001); + } + createCanvasSession(early.state); + if (!expired) { + initialization.commit(); + } + await timeout(0); + assert.deepStrictEqual([early.state.getChatCanvasStates(canvasChat).length, early.facet.calls], [expired ? 0 : 1, []]); + } + }); + }); + + test('lifecycle hydration preserves recorded native provenance without touching a provider', async () => { + await fixture.database.setTurnMessageOrigin('host-turn', CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN); + await fixture.database.setTurnEventId('host-turn', 'sdk-native'); + const contribution = store.add(new CanvasesContribution(new class extends mock() { }(), fixture.service, new NullLogService(), createSessionDataService(fixture.database), fixture.state)); + const turns: Turn[] = ['sdk-native', 'ordinary-user'].map(id => ({ id, message: { text: id, origin: { kind: MessageKind.User } }, responseParts: [], state: TurnState.Complete, usage: undefined })); + const restored = await contribution.onHydrateTurns({ session: canvasSession, chat: canvasChat }, turns); + assert.deepStrictEqual([restored.map(turn => turn.message.origin.kind), restored[0].message._meta?.copilotOrigin, fixture.facet.calls], [[MessageKind.Tool, MessageKind.User], CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN, []]); + }); + + test('live action declarations, not catalogue previews, authorize invocation', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + const params = { channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: 'action', actionId: 'increment' }; + await assert.rejects(connection.invokeCanvasAction({ ...params, requestId: 'preview', actionId: 'preview-only' })); + assert.deepStrictEqual(await connection.invokeCanvasAction(params), { result: { count: 1 } }); + }); + + test('action declarations are rechecked after asynchronous schema validation', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + const instance: IAgentCanvasInstance = { ...fixture.facet.instance(), availability: { status: CanvasAvailabilityStatus.Ready, actions: [{ id: 'increment', inputSchema: { type: 'object' } }] } }; + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [instance] }); + await timeout(0); + fixture.facet.beforeValidate = () => fixture.facet.publish({ ...fixture.facet.snapshot, instances: [fixture.facet.instance({ ...canvasIdentity })].map(instance => ({ ...instance, availability: { status: CanvasAvailabilityStatus.Ready, actions: [] } })) }); + await assert.rejects(connection.invokeCanvasAction({ channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: 'action', actionId: 'increment', input: {} }), conflict); + assert.strictEqual(fixture.facet.calls.includes('invoke'), false); + }); + + test('source pulls are fresh, independently authorized, and never persisted', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + const first = await connection.resolveCanvasSource({ channel: canvas.resource }); + fixture.facet.resolveResult = { url: 'http://127.0.0.1:8123/canvas?ephemeral=renewed' }; + const second = await connection.resolveCanvasSource({ channel: canvas.resource }); + fixture.facet.trust = { status: CanvasTrustStatus.Blocked }; + await assert.rejects(connection.resolveCanvasSource({ channel: canvas.resource }), denied); + assert.deepStrictEqual({ + refreshed: first.source?.url !== second.source?.url, + sameRevision: first.revision === second.revision, + persistedEndpoint: fixture.database.setMetadataCalls.some(call => call.value.includes('ephemeral') || call.value.includes('incarnation')), + pulls: fixture.facet.calls.filter(call => call.startsWith('resolve:')), + }, { refreshed: true, sameRevision: true, persistedEndpoint: false, pulls: ['resolve:client', 'resolve:client'] }); + }); + + test('superseded pulls lose presentation authority and restart invalidates stale preconditions', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + const gate = new DeferredPromise<{ url: string }>(); + fixture.facet.resolveGate = gate.p; + const pull = connection.resolveCanvasSource({ channel: canvas.resource }); + await connection.restartCanvasProvider({ channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: 'restart' }); + await gate.complete({ url: 'http://127.0.0.1:8123/stale' }); + assert.strictEqual((await pull).source, undefined); + await assert.rejects(connection.invokeCanvasAction({ channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: 'stale-action', actionId: 'increment' }), conflict); + await assert.rejects(connection.closeCanvas({ channel: canvas.resource, revision: canvas.revision, requestId: 'stale-close' }), conflict); + }); + + test('close removes membership, preserves retained session data and tolerates unknown resources', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + const current = fixture.state.getCanvasState(canvas.resource)!; + await connection.closeCanvas({ channel: canvas.resource, revision: current.revision, requestId: 'close' }); + await connection.closeCanvas({ channel: canvas.resource, revision: 0, requestId: 'close-unknown' }); + assert.deepStrictEqual({ members: fixture.state.getChatCanvasStates(canvasChat), sessionRetained: fixture.state.isUnusedDraft(canvasSession) }, { members: [], sessionRetained: false }); + }); + + test('connection disposal releases bounded operation residency and reports uncertainty', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + fixture.facet.invokeGate = new Promise(() => { }); + const pending = connection.invokeCanvasAction({ channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: 'long', actionId: 'increment' }); + await timeout(0); + assert.strictEqual(fixture.service.holdsSession(canvasSession), true); + const rejected = assert.rejects(pending, CanvasOperationIndeterminateError); + connection.dispose(); + await rejected; + await timeout(0); + assert.strictEqual(fixture.service.holdsSession(canvasSession), false); + }); + + test('restoration is metadata-only, with fresh incarnation and bounded identity', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + const calls = fixture.facet.calls.slice(); + const restored = await fixture.service.loadChat(canvasChat); + assert.deepStrictEqual({ + identities: restored.map(state => ({ ...state.identity, incarnation: 'fresh' })), + fresh: restored[0].identity.incarnation !== canvas.identity.incarnation, + revisionIncreased: restored[0].revision > canvas.revision, + availability: restored[0].availability, + trust: restored[0].trust, + calls: fixture.facet.calls, + }, { + identities: [{ ...canvasIdentity, incarnation: 'fresh' }], fresh: true, revisionIncreased: true, + availability: { status: CanvasAvailabilityStatus.NotLoaded }, trust: { status: CanvasTrustStatus.Pending }, calls, + }); + }); + + test('bad schema references and oversized results never lead to silent retries', async () => { + const connection = store.add(fixture.service.connect('client')); + fixture.facet.snapshot = { ...fixture.facet.snapshot, types: [{ ...fixture.facet.snapshot.types[0], openInputSchemaRef: 'ahp-canvas-schema:/missing' }] }; + await assert.rejects(connection.openCanvas(openParams), invalidParams); + fixture.facet.schemaReference = { type: 'object' }; + const { canvas } = await connection.openCanvas({ ...openParams, requestId: 'with-schema' }); + fixture.facet.invokeResult = 'x'.repeat(65536); + const params = { channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: 'large-result', actionId: 'increment' }; + await assert.rejects(connection.invokeCanvasAction(params), CanvasOperationIndeterminateError); + await assert.rejects(connection.invokeCanvasAction(params), CanvasOperationIndeterminateError); + assert.strictEqual(fixture.facet.calls.filter(call => call === 'invoke').length, 1); + }); + + test('native observations bind exact chat and reject an entire colliding snapshot', async () => { + const wrong = fixture.facet.instance({ ...canvasIdentity, chat: buildChatUri(canvasSession, 'peer') }); + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [fixture.facet.instance(), wrong] }); + await timeout(0); + assert.deepStrictEqual(fixture.state.getChatCanvasStates(canvasChat), []); + }); + + test('canvas state subscriptions reduce host actions without optimistic effect replay', async () => { + const connection = store.add(fixture.service.connect('client')); + const { canvas } = await connection.openCanvas(openParams); + const subscription = store.add(new CanvasStateSubscription(canvas.resource, 'client', () => { })); + const initial = fixture.state.getCanvasState(canvas.resource)!; + const fromSeq = fixture.state.serverSeq; + store.add(fixture.state.onDidEmitEnvelope(envelope => subscription.receiveEnvelope(envelope))); + fixture.state.dispatchServerAction(canvas.resource, { type: ActionType.CanvasTitleChanged, title: 'Changed', revision: canvas.revision + 1 }); + subscription.handleSnapshot(initial, fromSeq); + assert.strictEqual(subscription.verifiedValue?.title, 'Changed'); + }); +}); + +suite('Agent Host out-of-turn canvas approval', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('real client denial outside any model turn cannot be answered by autopilot', async () => { + const fixture = createCanvasServices(store); + createCanvasSession(fixture.state); + const calls: string[] = []; + store.add(fixture.connections.registerSource({ + hasSeenClient: () => true, isClientConnected: () => true, + getConnectedClientTransportCounts: () => new Map([['client', 1]]), getSubscribedClients: () => ['client'], + requestWorkspaceTrust: async () => true, + requestCanvasApproval: async (_client, request) => { calls.push(request.chat); return false; }, + })); + const approval = store.add(new AgentHostCanvasApproval(fixture.state, fixture.connections)); + assert.deepStrictEqual({ approved: await approval.request(canvasChat, 'Admit source?', CancellationToken.None), turn: fixture.state.getChatState(canvasChat)?.activeTurn, calls }, { approved: false, turn: undefined, calls: [canvasChat] }); + }); + + test('cancelled, late, ambiguous-client and headless grants fail closed', async () => { + const fixture = createCanvasServices(store); + createCanvasSession(fixture.state); + const gate = new DeferredPromise(); + store.add(fixture.connections.registerSource({ + hasSeenClient: () => true, isClientConnected: () => true, + getConnectedClientTransportCounts: () => new Map([['client', 1], ['other', 1]]), getSubscribedClients: () => ['client', 'other'], + requestWorkspaceTrust: async () => true, requestCanvasApproval: async () => gate.p, + })); + const approval = store.add(new AgentHostCanvasApproval(fixture.state, fixture.connections)); + assert.strictEqual(await approval.request(canvasChat, 'Ambiguous', CancellationToken.None), false); + const cancellation = store.add(new CancellationTokenSource()); + const pending = approval.request(canvasChat, 'Exact client', cancellation.token, 'client'); + cancellation.cancel(); + await gate.complete(true); + assert.strictEqual(await pending, false); + }); + + test('source prompts time out without executing or creating a turn', async () => { + await runWithFakedTimers({}, async () => { + const fixture = createCanvasServices(store); + createCanvasSession(fixture.state); + store.add(fixture.connections.registerSource({ + hasSeenClient: () => true, isClientConnected: () => true, + getConnectedClientTransportCounts: () => new Map([['client', 1]]), getSubscribedClients: () => ['client'], + requestWorkspaceTrust: async () => true, requestCanvasApproval: async () => new Promise(() => { }), + })); + const approval = store.add(new AgentHostCanvasApproval(fixture.state, fixture.connections)); + assert.strictEqual(await approval.request(canvasChat, 'Wait for a real person', CancellationToken.None), false); + }); + }); + + test('a grant arriving after its exact peer chat is removed is denied', async () => { + const fixture = createCanvasServices(store); + createCanvasSession(fixture.state); + const peer = buildChatUri(canvasSession, 'removed-peer'); + fixture.state.addChat(canvasSession, peer, { title: 'Peer' }); + const gate = new DeferredPromise(); + store.add(fixture.connections.registerSource({ + hasSeenClient: () => true, isClientConnected: () => true, + getConnectedClientTransportCounts: () => new Map([['client', 1]]), getSubscribedClients: () => ['client'], + requestWorkspaceTrust: async () => true, requestCanvasApproval: async () => gate.p, + })); + const approval = store.add(new AgentHostCanvasApproval(fixture.state, fixture.connections)); + const pending = approval.request(peer, 'Admit this source?', CancellationToken.None); + fixture.state.removeChat(canvasSession, peer); + await gate.complete(true); + assert.strictEqual(await pending, false); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts index a10a85a65cbb6..66fc6ea4853c9 100644 --- a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { unavailableCanvases } from '../common/agentHostCanvasesTestUtils.js'; +import { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; import { timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -272,6 +274,7 @@ suite('AgentSideEffects — tool call telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, clientConnectionService], + [IAgentHostCanvasesService, unavailableCanvases], [ISessionWorkspaceConversionService, { _serviceBrand: undefined, requestSessionWorkspaceUpdate: () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts index 9ff069c8a828c..0163049ce46ff 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnHangTelemetry.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { unavailableCanvases } from '../common/agentHostCanvasesTestUtils.js'; +import { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -228,6 +230,7 @@ suite('AgentSideEffects — turn hang telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, clientConnections], + [IAgentHostCanvasesService, unavailableCanvases], [ISessionWorkspaceConversionService, { _serviceBrand: undefined, requestSessionWorkspaceUpdate: () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 5937ffbd6563f..afcb3bf7de9cb 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { unavailableCanvases } from '../common/agentHostCanvasesTestUtils.js'; +import { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; import * as sinon from 'sinon'; import { Event } from '../../../../base/common/event.js'; import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -256,6 +258,7 @@ suite('AgentSideEffects — turn tracker telemetry', () => { [ISessionDataService, sessionDataService], [IAgentHostWorktreeIsolation, createNoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], + [IAgentHostCanvasesService, unavailableCanvases], [ISessionWorkspaceConversionService, { _serviceBrand: undefined, requestSessionWorkspaceUpdate: () => { }, diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 11dd9e3c433b0..9589abfa70c00 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -67,7 +67,9 @@ import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; -import { createTestAgentHostWorktreeIsolation, createTestAgentService, getTestAgentHostProviderService, getTestAgentHostWorktreeIsolation, getTestAgentServiceComposition, getTestAgentStateManager, registerTestAgentProvider, setTestAgentHostWorktreeIsolation } from './agentServiceTestUtils.js'; +import { createTestAgentHostWorktreeIsolation, createTestAgentService, getTestAgentHostCanvases, getTestAgentHostProviderService, getTestAgentHostWorktreeIsolation, getTestAgentServiceComposition, getTestAgentStateManager, registerTestAgentProvider, setTestAgentHostWorktreeIsolation } from './agentServiceTestUtils.js'; +import { TestCanvases } from './agentHostCanvasTestUtils.js'; +import { isCanvasSessionRetained } from '../../common/meta/agentCanvasSessionMeta.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -10280,6 +10282,136 @@ suite('AgentService (node dispatcher)', () => { // ---- createChat (multi-chat) ---------------------------------------- suite('createChat', () => { + test('no-turn retained canvas intent survives picker abandonment and empty-draft garbage collection', async () => { + const canvases = getTestAgentHostCanvases(service); + const facet = disposables.add(new TestCanvases()); + const agent = new class extends MockAgent { readonly canvases = facet; }('copilot'); + registerTestAgentProvider(service, agent); + const session = await service.createSession({ provider: 'copilot' }); + const chat = buildDefaultChatUri(session); + facet.snapshot = { ...facet.snapshot, chat }; + facet.initialized = false; + facet.onInitialize = (chat, operation) => canvases.retainChat(chat, operation.token); + const connection = disposables.add(canvases.connect('owner')); + await connection.initializeCanvasChat({ channel: chat, requestId: 'initialize' }); + const before = getStateManager(service).getSessionState(session.toString())!; + assert.deepStrictEqual({ + retained: isCanvasSessionRetained(getStateManager(service).getSessionSummary(session.toString())), + unused: getStateManager(service).isUnusedDraft(session.toString()), + turns: before.turns, active: before.activeTurn, members: getStateManager(service).getChatCanvasStates(chat), + }, { retained: true, unused: false, turns: [], active: undefined, members: [] }); + await runWithFakedTimers({ useFakeTimers: true }, async () => { + service.addSubscriber(session, 'owner'); + service.unsubscribe(session, 'owner'); + connection.dispose(); + await timeout(30_000); + }); + await service.restoreSession(session); + await timeout(0); + assert.deepStrictEqual({ + disposed: agent.disposeSessionCalls, + retained: isCanvasSessionRetained(getStateManager(service).getSessionSummary(session.toString())), + registered: (await service.getRegisteredSessions()).some(candidate => candidate.toString() === session.toString()), + initialized: facet.calls, + }, { disposed: [], retained: true, registered: true, initialized: ['initialize'] }); + }); + + for (const kind of ['main', 'peer', 'fork'] as const) { + test(`canvas ${kind} creation ingests native turns before publishing the real chat`, async () => { + const canvases = getTestAgentHostCanvases(service); + const session = AgentSession.uri('copilot', `pending-${kind}`); + const target = kind === 'main' ? buildDefaultChatUri(session) : buildChatUri(session, kind); + let pendingGeneration: string | undefined; + class InitializingAgent extends MockAgent { + readonly canvases = disposables.add(new TestCanvases()); + override async createChat(): Promise { } + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: async (chat, context, options) => { + if (chat.toString() === target) { + const lease = canvases.getChatInitialization(target); + assert.ok(lease); + lease.willExecute(); + pendingGeneration = getStateManager(service).getChatGeneration(target); + assert.strictEqual(getStateManager(service).getSnapshot(target), undefined); + this.fireProgress({ + kind: 'action', resource: chat, action: { + type: ActionType.ChatTurnStarted, turnId: `native-${kind}`, startedAt: '2026-01-01T00:00:00Z', + message: { text: 'Native initialization', origin: { kind: MessageKind.Tool } }, + } + }); + this.fireProgress({ kind: 'action', resource: chat, action: { type: ActionType.ChatTurnComplete, turnId: `native-${kind}`, duration: 0 } }); + } + return base.createChat(chat, context, options); + }, + })); + } + const agent = new InitializingAgent('copilot'); + registerTestAgentProvider(service, agent); + const config = { mode: 'plan' }; + await service.createSession({ provider: 'copilot', session, config }); + if (kind !== 'main') { + getStateManager(service).seedDefaultChatTurns(session.toString(), [{ + id: 'source-turn', state: TurnState.Complete, message: { text: 'Source', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, + }]); + await service.createChat(session, URI.parse(target), kind === 'fork' ? { fork: { source: session, turnId: 'source-turn' } } : undefined); + } + assert.deepStrictEqual({ + lastTurn: getStateManager(service).getChatState(target)?.turns.at(-1)?.id, + sameGeneration: getStateManager(service).getChatGeneration(target) === pendingGeneration, + held: canvases.holdsSession(session.toString()), unused: getStateManager(service).isUnusedDraft(session.toString()), + registered: getStateManager(service).getSessionState(session.toString())?.chats.some(chat => chat.resource === target), + mode: getStateManager(service).getSessionState(session.toString())?.config?.values.mode, + }, { lastTurn: `native-${kind}`, sameGeneration: true, held: false, unused: false, registered: true, mode: 'plan' }); + }); + + test(`canvas ${kind} creation cannot publish after its initializing transport disconnects`, async () => { + const canvases = getTestAgentHostCanvases(service); + const entered = new DeferredPromise(); + const release = new DeferredPromise(); + const session = AgentSession.uri('copilot', `cancelled-${kind}`); + const target = kind === 'main' ? buildDefaultChatUri(session) : buildChatUri(session, kind); + class InitializingAgent extends MockAgent { + readonly canvases = disposables.add(new TestCanvases()); + override async createChat(): Promise { } + override readonly chats: IAgentChats = withChatOverrides(getChatSurface(this), base => ({ + createChat: async (chat, context, options) => { + if (chat.toString() === target) { + assert.ok(canvases.getChatInitialization(target)); + await entered.complete(); + await release.p; + } + return base.createChat(chat, context, options); + }, + })); + } + const agent = new InitializingAgent('copilot'); + registerTestAgentProvider(service, agent); + if (kind !== 'main') { + await service.createSession({ provider: 'copilot', session }); + } + if (kind === 'fork') { + getStateManager(service).seedDefaultChatTurns(session.toString(), [{ + id: 'source-turn', state: TurnState.Complete, message: { text: 'Source', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, + }]); + } + const connection = disposables.add(canvases.connect('creator')); + const lease = disposables.add(connection.beginChatCreation(target)); + const creating = kind === 'main' + ? service.createSession({ provider: 'copilot', session }) + : service.createChat(session, URI.parse(target), kind === 'fork' ? { fork: { source: session, turnId: 'source-turn' } } : undefined); + const rejected = assert.rejects(creating, /Canceled/); + await entered.p; + connection.dispose(); + await release.complete(); + await rejected; + lease.dispose(); + assert.deepStrictEqual({ + state: getStateManager(service).getChatState(target), + registered: getStateManager(service).getSessionState(session.toString())?.chats.some(chat => chat.resource === target) ?? false, + held: canvases.holdsSession(session.toString()), + }, { state: undefined, registered: false, held: false }); + }); + } test('routes to the provider for a restored session not tracked in the provider map', async () => { // A session restored after a host restart lives in the state manager diff --git a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts index 8e3247bdb60e1..d03839c89ce7b 100644 --- a/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts +++ b/src/vs/platform/agentHost/test/node/agentServiceTestUtils.ts @@ -37,9 +37,28 @@ import { AgentHostLocalTurns, IAgentHostLocalTurns } from '../../node/agentHostL import { AgentHostLocalCommands, IAgentHostLocalCommands } from '../../node/localCommands/localChatCommand.js'; import { IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; import { IAgentHostWorktreeIsolation, NullAgentHostWorktreeIsolation } from '../../node/shared/worktreeIsolation.js'; +import { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; const compositions = new WeakMap(); const worktreeIsolations = new WeakMap(); +const canvasServices = new WeakMap(); +const clientConnections = new WeakMap(); + +export function getTestAgentHostCanvases(service: AgentService): IAgentHostCanvasesService { + const canvases = canvasServices.get(service); + if (!canvases) { + throw new Error('AgentService was not created by createTestAgentService'); + } + return canvases; +} + +export function getTestAgentHostClientConnections(service: AgentService): IAgentHostClientConnectionService { + const connections = clientConnections.get(service); + if (!connections) { + throw new Error('AgentService was not created by createTestAgentService'); + } + return connections; +} class MutableTestAgentHostWorktreeIsolation extends Disposable { private _delegate: IAgentHostWorktreeIsolation = new NullAgentHostWorktreeIsolation(); @@ -230,6 +249,8 @@ export function createTestAgentService( composition.setContributions(instantiationService.invokeFunction(accessor => activateAgentHostContributions(accessor, instantiationService))); compositions.set(composition.agentService, composition); worktreeIsolations.set(composition.agentService, worktreeIsolation); + canvasServices.set(composition.agentService, instantiationService.invokeFunction(accessor => accessor.get(IAgentHostCanvasesService))); + clientConnections.set(composition.agentService, clientConnectionService); return composition.agentService; } catch (error) { composition.agentService.dispose(); diff --git a/src/vs/platform/agentHost/test/node/agentSessionResidency.test.ts b/src/vs/platform/agentHost/test/node/agentSessionResidency.test.ts index 96aa330222253..af656fb8691a0 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionResidency.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionResidency.test.ts @@ -21,6 +21,7 @@ import { MessageKind, SessionStatus, buildChatUri, buildDefaultChatUri, buildSub import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostSubscriptionService } from '../../node/agentHostSubscriptionService.js'; import { AgentSessionResidency, type IAgentSessionReleaseDelegate } from '../../node/agentSessionResidency.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus } from '../../common/state/protocol/channels-canvas/state.js'; suite('AgentSessionResidency', () => { const disposables = new DisposableStore(); @@ -38,7 +39,7 @@ suite('AgentSessionResidency', () => { releaseHold = disposables.add(new Emitter()); released = []; evicted = []; - subscriptions = new AgentHostSubscriptionService(); + subscriptions = new AgentHostSubscriptionService(stateManager); delegate = { isReleaseBlocked: () => false, whenSessionDataIdle: async () => { }, @@ -58,6 +59,24 @@ suite('AgentSessionResidency', () => { teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); + test('a canvas-only subscriber holds its exact owner without initializing another session', async () => { + const owner = createUsedSession('canvas-owner'); + const canvas = URI.parse('ahp-canvas:/resident'); + stateManager.registerCanvas({ + resource: canvas.toString(), + identity: { chat: buildDefaultChatUri(owner), source: { kind: CanvasSourceKind.Extension, extensionId: 'project:counter' }, canvasType: 'counter', instanceId: 'main', incarnation: 'first' }, + title: 'Counter', availability: { status: CanvasAvailabilityStatus.Ready, actions: [] }, trust: { status: CanvasTrustStatus.Trusted }, revision: 1, + }); + subscriptions.addSubscriber(canvas, 'canvas-view'); + residency.dispose(); + residency = createResidency(0); + residency.touch(canvas); + await residency.reconcile(); + assert.deepStrictEqual([subscriptions.hasSessionSubscribers(owner), released], [true, []]); + subscriptions.removeSubscriber(canvas, 'canvas-view'); + await residency.reconcile(); + assert.deepStrictEqual(released, [owner.toString()]); + }); function createResidency(limit: number, releaseRetryMs = 30_000): AgentSessionResidency { const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( [ILogService, logService], diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 35745be945654..a53d9895ca828 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { unavailableCanvases } from '../common/agentHostCanvasesTestUtils.js'; +import { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; @@ -180,6 +182,7 @@ function createTestSideEffects( [ISessionDataService, options.sessionDataService], [IAgentHostWorktreeIsolation, new NoopWorktreeIsolation()], [IAgentHostClientConnectionService, disposables.add(new AgentHostClientConnectionService())], + [IAgentHostCanvasesService, unavailableCanvases], ); services.set(ISessionWorkspaceConversionService, { _serviceBrand: undefined, diff --git a/src/vs/platform/agentHost/test/node/chatContributions.test.ts b/src/vs/platform/agentHost/test/node/chatContributions.test.ts index 5baca560cdf6f..c3d402d4c9cc9 100644 --- a/src/vs/platform/agentHost/test/node/chatContributions.test.ts +++ b/src/vs/platform/agentHost/test/node/chatContributions.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { unavailableCanvases } from '../common/agentHostCanvasesTestUtils.js'; +import { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; @@ -834,6 +836,7 @@ function createBuiltInContributions(disposables: ReturnType, options?: { type CopilotCreateSessionOptions = Parameters[0]; -function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot; readonly workingDirectory?: URI; readonly additionalDirectories?: readonly URI[] }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { +function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot; readonly workingDirectory?: URI; readonly additionalDirectories?: readonly URI[]; readonly duringCreate?: (config: CopilotCreateSessionOptions) => Promise }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined; readonly initializePending: () => Promise } { const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); const shellManager = instantiationService.createInstance(ShellManager, sessionUri, options?.workingDirectory); let createOptions: CopilotCreateSessionOptions | undefined; @@ -1208,14 +1210,16 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic const agentInternals = (agent as unknown as { _getOrCreateActiveClient: (session: URI, directory: URI | undefined) => { readonly toolSet: ActiveClientToolSet }; _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown) => CopilotAgentSession; + _initializeAndRegisterSession: (session: CopilotAgentSession, register: () => void) => Promise; }); const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, options?.workingDirectory); const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client: { - createSession: async options => { - createOptions = options; - reportManagedSettings(options); + createSession: async config => { + createOptions = config; + reportManagedSettings(config); + await options?.duringCreate?.(config); return mockSession as unknown as CopilotSession; }, resumeSession: async (_id, options) => { reportManagedSettings(options); return mockSession as unknown as CopilotSession; }, @@ -1233,7 +1237,8 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic githubCredentials: CopilotGitHubSessionCredentials.fromToken('token'), model: undefined, }; - return { session: agentInternals._createAgentSession(launchPlan, options?.workingDirectory, activeClient), activeClient, createOptions: () => createOptions }; + const session = agentInternals._createAgentSession(launchPlan, options?.workingDirectory, activeClient); + return { session, activeClient, createOptions: () => createOptions, initializePending: () => agentInternals._initializeAndRegisterSession(session, () => { }) }; } function withoutUndefinedProperties(metadata: IAgentChatMetadata): Record { @@ -11162,6 +11167,66 @@ suite('CopilotAgent', () => { suite('exact chat routing and lifecycle', () => { + test('canvas preparation cannot initialize a provisional worktree in the picked repository', async () => { + const client = new TestCopilotClient([]); + const { agent, stateManager } = createTestAgentContext(disposables, { copilotClient: client, sessionDataService: disposables.add(new TestSessionDataService()) }); + let executed = false; + try { + await agent.authenticate('https://api.github.com', 'test-token'); + const result = await provisionSession(agent, { session: AgentSession.uri('copilotcli', 'unprepared-canvas'), workingDirectories: [URI.file('/workspace')] }); + stateManager.createSession({ resource: result.session.toString(), provider: agent.id, title: 'Canvas', status: SessionStatus.Idle, createdAt: '2026-01-01T00:00:00Z', modifiedAt: '2026-01-01T00:00:00Z' }); + stateManager.setSessionConfig(result.session.toString(), { schema: platformSessionSchema.toProtocol(), values: { isolation: 'worktree' } }); + const preparation = agent as unknown as { _prepareCanvasChat(chat: string, operation: IAgentCanvasOperation): Promise }; + await assert.rejects(preparation._prepareCanvasChat(buildDefaultChatUri(result.session), { token: CancellationToken.None, willExecute: () => { executed = true; } }), /host-owned worktree preparation/); + assert.strictEqual(executed, false); + } finally { + await disposeAgent(agent); + } + }); + + test('permission, input and client-tool callbacks can finish before SDK create resolves', async () => { + const { agent, instantiationService, fileService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService: disposables.add(new TestSessionDataService()) }); + disposables.add(registerPendingEditContentProvider(fileService)); + const responses: object[] = []; + const created = createAgentSessionThroughAgent(agent, instantiationService, { + duringCreate: async config => { + assert.ok(config.onPermissionRequest && config.onUserInputRequest); + const permission: PermissionRequest = { + kind: 'write', toolCallId: 'early-permission', canOfferSessionApproval: false, + fileName: URI.file('/outside/file.txt').fsPath, intention: 'write file', + diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+after', newFileContents: 'after', + }; + const invocation = { sessionId: 'test-session-1' }; + responses.push(await config.onPermissionRequest(permission, invocation)); + responses.push(await config.onUserInputRequest({ question: 'Continue?', choices: ['Yes', 'No'] }, invocation)); + responses.push(await config.onPermissionRequest({ ...permission, toolCallId: 'early-client-tool' }, invocation)); + }, + }); + const session = disposables.add(created.session); + session.resetTurnState('early-runtime-turn'); + disposables.add(agent.onDidChatProgress(signal => { + if (signal.kind === 'pending_confirmation') { + if (signal.state.toolCallId === 'early-client-tool') { + agent.onClientToolCallComplete(session.chatChannelUri, signal.state.toolCallId, { success: false, pastTenseMessage: 'Client tool failed', error: { message: 'Failed before permission' } }); + } else { + agent.respondToPermissionRequest(signal.state.toolCallId, false); + } + } else if (signal.kind === 'action' && signal.action.type === ActionType.ChatInputRequested) { + agent.respondToUserInputRequest(signal.action.request.id, ChatInputResponseKind.Cancel); + } + })); + try { + await created.initializePending(); + assert.deepStrictEqual(responses, [ + { kind: 'reject', feedback: 'The user denied permission.' }, + { answer: '', wasFreeform: true }, + { kind: 'approve-once' }, + ]); + } finally { + await disposeAgent(agent); + } + }); + /** Installs a stub chat leaf into the owning session's entry, keyed by the chat URI. */ function installStubChat(agent: CopilotAgent, chatUri: URI, options?: { permissionOwner?: string; inputOwner?: string }) { const events: string[] = []; diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 7a4aa4a3128f8..1e65d2c35c997 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type Anthropic from '@anthropic-ai/sdk'; -import type { CopilotSession, CurrentToolMetadata, PermissionMode, PermissionRequest, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; +import type { CopilotSession, CurrentToolMetadata, PermissionMode, PermissionRequest, PermissionRequestResult, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, Tool, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'fs'; @@ -15,6 +15,7 @@ import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; import { join, sep } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; @@ -39,7 +40,7 @@ import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService, type ISessionDatabase } from '../../common/sessionDataService.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction, type StateAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, createSessionState, getInlineToolInput, mergeSessionWithDefaultChat, readSessionPromptCacheState, readUsageInfoMeta, SessionStatus, withSessionPromptCacheState, type ToolResultContent, type ToolResultFileEditContent, type ToolResultTerminalContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; import { TerminalClaimKind } from '../../common/state/protocol/state.js'; import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAttachmentMeta.js'; import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js'; @@ -53,7 +54,9 @@ import { buildSandboxConfigForSdk, type SandboxConfig } from '../../node/copilot import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; import { type IShellInitScript } from '../../common/shellInitScript.js'; -import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; +import { CopilotSessionEventBuffer, CopilotSessionWrapper, type ICopilotModelCallFinishedEvent } from '../../node/copilot/copilotSessionWrapper.js'; +import { extensionContextToProtocol } from '../../node/copilot/copilotAttachmentUtils.js'; +import { CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN } from '../../common/agentHostCanvases.js'; import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentHostClientConnectionService } from '../../node/agentHostClientConnectionService.js'; import { AgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; @@ -73,6 +76,12 @@ import { CLIENT_TOOL_SEARCH_REFERENCE_NAME, RUNTIME_TOOL_SEARCH_TOOL_NAME } from import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { createNoopGitService, createSessionDataService, createZeroDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +import { createTestAgentService, getTestAgentHostCanvases, getTestAgentHostClientConnections, getTestAgentStateManager, registerTestAgentProvider } from './agentServiceTestUtils.js'; +import { canvasChat, canvasSession, TestCanvases } from './agentHostCanvasTestUtils.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { mock } from '../../../../base/test/common/mock.js'; import { OtelData } from '../../common/otlp/otlpLogEmitter.js'; import { type IAgentServerToolDefinition, IAgentServerToolHost } from '../../common/agentServerTools.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; @@ -127,6 +136,8 @@ class MockCopilotSession { shellInitScriptUpdateSuccess = true; abortCalls = 0; abortGate: Promise | undefined; + queuePendingItems: Awaited>['items'] = []; + readonly queueRemoveAtCalls: Parameters[0][] = []; modelGate: Promise | undefined; readonly setModelCalls: Parameters[] = []; agentSelectGate: Promise | undefined; @@ -313,6 +324,15 @@ class MockCopilotSession { } readonly rpc = { + queue: { + pendingItems: async (): ReturnType => ({ items: this.queuePendingItems, steeringMessages: [] }), + removeAt: async (params: Parameters[0]): ReturnType => { + this.queueRemoveAtCalls.push(params); + const before = this.queuePendingItems.length; + this.queuePendingItems = this.queuePendingItems.filter(item => item.id !== params.id); + return { removed: this.queuePendingItems.length !== before }; + }, + }, agent: { select: async () => { await this.agentSelectGate; }, deselect: async () => { await this.agentDeselectGate; }, @@ -836,6 +856,11 @@ async function createAgentSession(disposables: DisposableStore, options?: { resume?: boolean; initializeEnablementSession?: (session: string) => Promise; beforeLaunch?: () => void; + /** Actual early runtime notifications, delivered before the public SDK session is returned. */ + canvasEvents?: readonly SessionEvent[]; + onSignal?: (signal: AgentSignal) => void; + onSessionCreated?: (session: CopilotAgentSession) => void; + beforeSdkReturn?: (runtime: ICopilotSessionRuntime, session: MockCopilotSession, emit: (event: SessionEvent) => void) => Promise; realpath?: (path: string) => Promise; }): Promise<{ session: CopilotAgentSession; @@ -860,6 +885,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { disposables.add(progressEmitter.event(signal => { signals.push(signal); + options?.onSignal?.(signal); for (let i = waiters.length - 1; i >= 0; i--) { if (waiters[i].predicate(signal)) { const { deferred } = waiters[i]; @@ -921,6 +947,16 @@ async function createAgentSession(disposables: DisposableStore, options?: { if (options?.captureRuntime) { options.captureRuntime.current = runtime; } + if (options?.canvasEvents) { + const wrapper = new CopilotSessionWrapper(mockSession.sessionId); + runtime.onSessionStarting?.(wrapper); + for (const event of options.canvasEvents) { + wrapper.acceptSessionEvent(event); + } + await options.beforeSdkReturn?.(runtime, mockSession, event => wrapper.acceptSessionEvent(event)); + await wrapper.attachSession(mockSession as unknown as CopilotSession); + return wrapper; + } return new CopilotSessionWrapper(mockSession as unknown as CopilotSession); } }; @@ -1130,6 +1166,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { }, )); + options?.onSessionCreated?.(session); await session.initializeSession(); if (!launchedRuntime) { throw new Error('Expected session runtime'); @@ -1214,6 +1251,54 @@ function createTestShellManager(disposables: DisposableStore, workingDirectory: return { shellManager, preflightCalls: () => preflightCalls, setCalls }; } +async function createNativeHostComposition(disposables: DisposableStore, canvasEvents: readonly SessionEvent[] = []) { + const logService = new NullLogService(); + const files = disposables.add(new FileService(logService)); + disposables.add(files.registerProvider(Schemas.file, disposables.add(new InMemoryFileSystemProvider()))); + const service = disposables.add(createTestAgentService(logService, files, createSessionDataService(), new class extends mock() { }(), createNoopGitService())); + const state = getTestAgentStateManager(service); + const canvases = getTestAgentHostCanvases(service); + const facet = disposables.add(new TestCanvases()); + facet.initialized = false; + facet.defersHostTurnStart = true; + let native: Awaited> | undefined; + let nativeSession: CopilotAgentSession | undefined; + const initialized = new DeferredPromise(); + const agent = new class extends MockAgent { + readonly canvases = facet; + constructor() { + super('copilot'); + this.chats.abort = async (_chat, _context, turnId) => { + assert.ok(nativeSession); + await nativeSession.abort(turnId); + }; + } + override async sendMessage(...args: Parameters): Promise { + await super.sendMessage(...args); + assert.ok(nativeSession); + await nativeSession.send(args[2], args[3], args[4], undefined, args[5], args[6]); + } + override respondToPermissionRequest(requestId: string, approved: boolean): void { + assert.ok(nativeSession); + nativeSession.respondToPermissionRequest(requestId, approved); + } + }(); + registerTestAgentProvider(service, agent); + await service.createSession({ provider: 'copilot', session: URI.parse(canvasSession) }); + facet.onInitialize = async chat => { + native = await createAgentSession(disposables, { + sessionUri: URI.parse(canvasSession), chatChannelUri: URI.parse(chat), + canvasEvents, onSignal: signal => agent.fireProgress(signal), onSessionCreated: session => { nativeSession = session; }, + }); + await initialized.complete(); + }; + return { + service, state, canvases, facet, agent, initialized: initialized.p, + bindNativeSession: (session: CopilotAgentSession) => { nativeSession = session; }, + get native() { assert.ok(native); return native; }, + }; +} + suite('CopilotAgentSession', () => { const disposables = new DisposableStore(); @@ -1809,6 +1894,39 @@ suite('CopilotAgentSession', () => { }]); }); + for (const buffering of ['startup', 'send acknowledgement'] as const) { + test(`model.call_finished survives ${buffering} buffering without replaying effects`, () => { + const mockSession = new MockCopilotSession(); + const wrapper = disposables.add(new CopilotSessionWrapper( + mockSession as unknown as CopilotSession, + buffering === 'startup' ? new CopilotSessionEventBuffer() : undefined, + )); + const acknowledgement = buffering === 'send acknowledgement' ? disposables.add(wrapper.bufferEventsUntilAcknowledged()) : undefined; + const events: ICopilotModelCallFinishedEvent[] = []; + const unhandled: string[] = []; + disposables.add(wrapper.onModelCallFinished(event => events.push(event))); + disposables.add(wrapper.onUnhandledEvent(event => unhandled.push(event.type))); + const event: ICopilotModelCallFinishedEvent = { + id: 'buffered-model-call', agentId: undefined, + data: { + turnId: 'sdk-turn', interactionId: 'interaction', dispatchDurationMs: 125, + outcome: 'success', containsBuiltInFileEditRequest: true, editClassifierVersion: 1, + }, + }; + mockSession.fireRaw({ ...event, type: 'model.call_finished', ephemeral: true }); + const beforeRelease = events.slice(); + if (acknowledgement) { + acknowledgement.dispose(); + } else { + wrapper.releaseBufferedEvents(); + } + wrapper.releaseBufferedEvents(); + assert.deepStrictEqual({ + beforeRelease, events, unhandled, sends: mockSession.sendRequests, messageSends: mockSession.sendMessagesRequests, + }, { beforeRelease: [], events: [event], unhandled: [], sends: [], messageSends: [] }); + }); + } + test('reports a completed disconnect separately from a pending disconnect', async () => { const disconnectGate = new DeferredPromise(); const mockSession = new MockCopilotSession(); @@ -2287,6 +2405,321 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual((await session.getMessages())[0].message.attachments, [attachment]); }); + test('early native messages retain their own turn boundaries and never resend through the SDK', async () => { + const database = new TestSessionDatabase(); + const metadata = { timestamp: '2026-01-01T00:00:00Z', parentId: null }; + const chat = URI.parse(buildChatUri('copilotcli:/test-session-1', 'peer')); + const { signals, mockSession } = await createAgentSession(disposables, { + sessionDatabase: database, chatChannelUri: chat, + canvasEvents: [ + { ...metadata, type: 'user.message', id: 'native-first', data: { content: 'First native message' } }, + { ...metadata, type: 'assistant.message_delta', id: 'delta-first', ephemeral: true, data: { messageId: 'response-first', deltaContent: 'First response' } }, + { ...metadata, type: 'user.message', id: 'native-second', data: { content: 'Second native message' } }, + { ...metadata, type: 'assistant.message_delta', id: 'delta-second', ephemeral: true, data: { messageId: 'response-second', deltaContent: 'Second response' } }, + ], + }); + mockSession.fire('user.message', { content: 'Duplicate observation' }, { id: 'native-second' }); + const starts = getActions(signals).filter(action => action.type === ActionType.ChatTurnStarted); + const responses = getActions(signals).flatMap(action => + action.type === ActionType.ChatResponsePart && action.part.kind === ResponsePartKind.Markdown + ? [[starts.findIndex(start => start.turnId === action.turnId), action.part.content]] + : action.type === ActionType.ChatDelta + ? [[starts.findIndex(start => start.turnId === action.turnId), action.content]] + : []); + const origins = await database.getTurnMessageOrigins(); + assert.deepStrictEqual({ + messages: starts.map(action => [action.message.text, action.message.origin.kind]), + responses, + chats: [...new Set(signals.filter((signal): signal is IAgentActionSignal => signal.kind === 'action').map(signal => signal.resource.toString()))], + sends: [mockSession.sendRequests, mockSession.sendMessagesRequests], + origins: [origins.get('native-first'), origins.get('native-second')], + }, { + messages: [['First native message', MessageKind.Tool], ['Second native message', MessageKind.Tool]], + responses: [[0, 'First response'], [1, 'Second response']], + chats: [chat.toString()], sends: [[], []], origins: [CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN, CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN], + }); + }); + + test('native messages during a live chat do not steal an existing SDK echo', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables, { canvasEvents: [] }); + await session.send('The host echo', undefined, 'host-turn'); + mockSession.fire('user.message', { content: 'The host echo', messageId: 'message-1' }, { id: 'host-echo' }); + mockSession.fire('user.message', { content: 'Native follow-up' }, { id: 'native-follow-up' }); + const starts = getActions(signals).filter(action => action.type === ActionType.ChatTurnStarted); + assert.deepStrictEqual(starts.map(action => [action.turnId, action.message.text, action.message.origin.kind]), [ + ['host-turn', 'The host echo', MessageKind.User], ['native-follow-up', 'Native follow-up', MessageKind.Tool], + ]); + }); + + test('host composition initializes before admission and correlates the original user echo before its ACK', async () => { + const metadata = { timestamp: '2026-01-01T00:00:00Z', parentId: null }; + const f = await createNativeHostComposition(disposables, [ + { ...metadata, type: 'user.message', id: 'native-first', data: { content: 'Native initialization' } }, + { ...metadata, type: 'assistant.message_delta', id: 'native-response', ephemeral: true, data: { messageId: 'response-native', deltaContent: 'Native answer' } }, + { ...metadata, type: 'session.idle', id: 'native-idle', ephemeral: true, data: {} }, + ]); + const echoed: Array<{ id: string; clientId: string | undefined }> = []; + disposables.add(f.state.onDidEmitEnvelope(envelope => { + if (envelope.action.type === ActionType.ChatTurnStarted) { + echoed.push({ id: envelope.action.turnId, clientId: envelope.origin?.clientId }); + } + })); + f.service.dispatchAction(canvasChat, { + type: ActionType.ChatTurnStarted, turnId: 'host-turn', startedAt: '2026-01-01T00:00:01Z', + message: { text: 'Original host input', origin: { kind: MessageKind.User }, _meta: { original: true } }, + }, 'owner', 1); + await f.initialized; + const acknowledgement = new DeferredPromise(); + f.native.mockSession.sendGate = acknowledgement.p; + while (!f.native.mockSession.sendRequests.length) { + await timeout(0); + } + assert.deepStrictEqual([f.state.getActiveTurnId(canvasChat), f.state.getDeferredTurnId(canvasChat)], [undefined, 'host-turn']); + f.native.mockSession.fire('user.message', { content: 'SDK-scaffolded host input', messageId: 'message-1' }, { id: 'host-event' }); + f.native.mockSession.fire('assistant.message_delta', { messageId: 'host-response', deltaContent: 'Host answer' }, { id: 'host-delta' }); + f.native.mockSession.fire('session.idle', {}, { id: 'host-idle' }); + await acknowledgement.complete(); + await timeout(0); + await timeout(0); + assert.deepStrictEqual({ + echoed, + turns: f.state.getChatState(canvasChat)?.turns.map(turn => ({ + id: turn.id, message: turn.message, response: turn.responseParts.flatMap(part => part.kind === ResponsePartKind.Markdown ? [part.content] : []), + })), + sends: f.native.mockSession.sendRequests.length, + }, { + echoed: [{ id: 'native-first', clientId: undefined }, { id: 'host-turn', clientId: 'owner' }], + turns: [ + { id: 'native-first', message: { text: 'Native initialization', origin: { kind: MessageKind.Tool }, attachments: undefined, _meta: { copilotOrigin: CANVAS_EXTERNAL_RUNTIME_MESSAGE_ORIGIN, sdkEventId: 'native-first' } }, response: ['Native answer'] }, + { id: 'host-turn', message: { text: 'Original host input', origin: { kind: MessageKind.User }, _meta: { original: true } }, response: ['Host answer'] }, + ], + sends: 1, + }); + }); + + test('host composition keeps a native turn before the host ACK distinct from the deferred host turn', async () => { + const f = await createNativeHostComposition(disposables); + const connection = disposables.add(f.canvases.connect('owner')); + await connection.initializeCanvasChat({ channel: canvasChat, requestId: 'initialize' }); + const acknowledgement = new DeferredPromise(); + f.native.mockSession.sendGate = acknowledgement.p; + f.service.dispatchAction(canvasChat, { + type: ActionType.ChatTurnStarted, turnId: 'host-turn', startedAt: '2026-01-01T00:00:01Z', + message: { text: 'Host', origin: { kind: MessageKind.User } }, + }, 'owner', 1); + while (!f.native.mockSession.sendRequests.length) { + await timeout(0); + } + f.native.mockSession.fire('user.message', { content: 'Native', messageId: 'native-message-id' }, { id: 'native-event' }); + f.native.mockSession.fire('assistant.message_delta', { messageId: 'native-answer', deltaContent: 'Native answer' }, { id: 'native-delta' }); + f.native.mockSession.fire('user.message', { content: 'Host', messageId: 'message-1' }, { id: 'host-event' }); + f.native.mockSession.fire('assistant.message_delta', { messageId: 'host-answer', deltaContent: 'Host answer' }, { id: 'host-delta' }); + f.native.mockSession.fire('session.idle', {}, { id: 'idle' }); + await acknowledgement.complete(); + await timeout(0); + await timeout(0); + assert.deepStrictEqual(f.state.getChatState(canvasChat)?.turns.map(turn => [ + turn.id, turn.message.origin.kind, turn.message.text, + turn.responseParts.flatMap(part => part.kind === ResponsePartKind.Markdown ? [part.content] : []), + ]), [ + ['native-event', MessageKind.Tool, 'Native', ['Native answer']], + ['host-turn', MessageKind.User, 'Host', ['Host answer']], + ]); + assert.strictEqual(f.native.mockSession.sendRequests.length, 1); + }); + + test('canvas preflight leaves local commands local and the archived-chat gate synchronous', async () => { + const f = await createNativeHostComposition(disposables); + f.service.dispatchAction(canvasChat, { + type: ActionType.ChatTurnStarted, turnId: 'rename', startedAt: '2026-01-01T00:00:00Z', + message: { text: '/rename New Title', origin: { kind: MessageKind.User } }, + }, 'owner', 1); + assert.strictEqual(f.state.getSessionState(canvasSession)?.title, 'New Title'); + await timeout(0); + f.state.dispatchServerAction(canvasSession, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + f.service.dispatchAction(canvasChat, { + type: ActionType.ChatTurnStarted, turnId: 'rejected', startedAt: '2026-01-01T00:00:01Z', + message: { text: 'Must not initialize', origin: { kind: MessageKind.User } }, + }, 'owner', 2); + assert.deepStrictEqual({ + active: f.state.getActiveTurnId(canvasChat), + last: f.state.getChatState(canvasChat)?.turns.at(-1)?.id, + parts: f.state.getChatState(canvasChat)?.turns.at(-1)?.responseParts.map(part => part.kind), + calls: f.facet.calls, sends: f.agent.sendMessageCalls, + }, { active: undefined, last: 'rejected', parts: [ResponsePartKind.Error], calls: [], sends: [] }); + }); + + for (const queued of [false, true]) { + test(`host composition cancels only its ${queued ? 'queued' : 'direct'} pending send and preserves a native subagent`, async () => { + const f = await createNativeHostComposition(disposables); + const connection = disposables.add(f.canvases.connect('owner')); + await connection.initializeCanvasChat({ channel: canvasChat, requestId: 'initialize' }); + const acknowledgement = new DeferredPromise(); + f.native.mockSession.sendGate = acknowledgement.p; + f.service.dispatchAction(canvasChat, queued ? { + type: ActionType.ChatPendingMessageSet, kind: PendingMessageKind.Queued, id: 'queued', + message: { text: 'Host', origin: { kind: MessageKind.User } }, + } : { + type: ActionType.ChatTurnStarted, turnId: 'host-turn', startedAt: '2026-01-01T00:00:01Z', + message: { text: 'Host', origin: { kind: MessageKind.User } }, + }, 'owner', 1); + while (!f.native.mockSession.sendRequests.length) { + await timeout(0); + } + const turnId = f.state.getDeferredTurnId(canvasChat); + assert.ok(turnId); + f.native.mockSession.queuePendingItems = [ + { id: 'host-queue', messageId: 'message-1', kind: 'message', displayText: 'Host', agentMode: 'interactive' }, + { id: 'other-queue', messageId: 'other-message', kind: 'message', displayText: 'Other', agentMode: 'interactive' }, + ]; + f.native.mockSession.fire('user.message', { content: 'Native', messageId: 'native-message' }, { id: 'native-event' }); + f.native.mockSession.fire('subagent.started', { + toolCallId: 'native-task', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Native child', + }, { agentId: 'native-child' }); + await acknowledgement.complete(); + await timeout(0); + await timeout(0); + const childChat = buildSubagentChatUri(canvasSession, 'native-task'); + const childTurn = f.state.getActiveTurnId(childChat); + assert.ok(childTurn); + f.service.dispatchAction(canvasChat, { type: ActionType.ChatTurnCancelled, turnId, duration: 0 }, 'owner', 2); + await timeout(0); + const childSurvived = f.state.getActiveTurnId(childChat) === childTurn; + f.native.mockSession.fire('assistant.message_delta', { messageId: 'native-answer', deltaContent: 'Native answer' }); + f.native.mockSession.fire('session.idle', {}); + await timeout(0); + await timeout(0); + assert.deepStrictEqual({ + childSurvived, + removed: f.native.mockSession.queueRemoveAtCalls, + remainingSdkQueue: f.native.mockSession.queuePendingItems.map(item => item.id), + aborts: f.native.mockSession.abortCalls, + turns: f.state.getChatState(canvasChat)?.turns.map(turn => turn.id), + deferred: f.state.getDeferredTurnId(canvasChat), + queue: f.state.getChatState(canvasChat)?.queuedMessages, + sends: f.native.mockSession.sendRequests.length, + }, { + childSurvived: true, removed: [{ id: 'host-queue' }], remainingSdkQueue: ['other-queue'], aborts: 0, + turns: ['native-event'], deferred: undefined, queue: undefined, sends: 1, + }); + }); + } + + test('native pending-turn cancellation rejects a batch containing another message', async () => { + const { session, mockSession } = await createAgentSession(disposables, { canvasEvents: [] }); + await session.send('Host', undefined, 'host-turn'); + mockSession.fire('user.message', { content: 'Native' }, { id: 'native-event' }); + mockSession.queuePendingItems = [ + { id: 'batch', messageId: 'message-1', kind: 'message', displayText: 'Host', agentMode: 'interactive' }, + { id: 'batch', messageId: 'other-message', kind: 'message', displayText: 'Other', agentMode: 'interactive' }, + ]; + await assert.rejects(session.abort('host-turn'), /contains another turn/); + assert.deepStrictEqual([mockSession.queueRemoveAtCalls, mockSession.abortCalls], [[], 0]); + }); + + test('cold queued sends initialize before committing their original queue identity', async () => { + const f = await createNativeHostComposition(disposables); + f.service.dispatchAction(canvasChat, { + type: ActionType.ChatPendingMessageSet, kind: PendingMessageKind.Queued, id: 'queued', + message: { text: 'Queued host input', origin: { kind: MessageKind.User } }, + }, 'owner', 1); + await f.initialized; + while (!f.native.mockSession.sendRequests.length) { + await timeout(0); + } + const turnId = f.state.getDeferredTurnId(canvasChat); + assert.ok(turnId); + assert.deepStrictEqual(f.state.getChatState(canvasChat)?.queuedMessages?.map(message => message.id), ['queued']); + f.native.mockSession.fire('user.message', { content: 'Queued host input', messageId: 'message-1' }, { id: 'queued-event' }); + f.native.mockSession.fire('assistant.message_delta', { messageId: 'answer', deltaContent: 'Queued answer' }, { id: 'queued-delta' }); + f.native.mockSession.fire('session.idle', {}, { id: 'queued-idle' }); + await timeout(0); + assert.deepStrictEqual({ + turns: f.state.getChatState(canvasChat)?.turns.map(turn => [turn.id, turn.message.text]), + queue: f.state.getChatState(canvasChat)?.queuedMessages, sends: f.native.mockSession.sendRequests.length, + }, { turns: [[turnId, 'Queued host input']], queue: undefined, sends: 1 }); + }); + + test('an observation buffer failure does not replace the original rejected SDK send error', async () => { + const { session, mockSession } = await createAgentSession(disposables, { canvasEvents: [] }); + const acknowledgement = new DeferredPromise(); + mockSession.sendGate = acknowledgement.p; + const error = new Error('Original SDK failure'); + const rejected = assert.rejects(session.send('Host', undefined, 'host-turn'), candidate => candidate === error); + while (!mockSession.sendRequests.length) { + await timeout(0); + } + for (let index = 0; index < 1025; index++) { + mockSession.fire('assistant.message_delta', { messageId: 'response', deltaContent: 'x' }, { id: `event-${index}` }); + } + await acknowledgement.error(error); + await rejected; + }); + + test('pre-return native tools settle against pending peer state and the original human approval', async () => { + const f = await createNativeHostComposition(disposables); + disposables.add(getTestAgentHostClientConnections(f.service).registerSource({ + hasSeenClient: id => id === 'owner', + isClientConnected: id => id === 'owner', + getConnectedClientTransportCounts: () => new Map([['owner', 1]]), + requestWorkspaceTrust: async () => false, + })); + const approvals: string[] = []; + const connection = disposables.add(f.canvases.connect('owner', async request => { + approvals.push(request.chat); + return true; + })); + const peer = buildChatUri(canvasSession, 'before-return'); + const lease = disposables.add(connection.beginChatCreation(peer)); + const metadata = { timestamp: '2026-01-01T00:00:00Z', parentId: null }; + let permission: PermissionRequestResult | undefined; + const native = await createAgentSession(disposables, { + sessionUri: URI.parse(canvasSession), chatChannelUri: URI.parse(peer), + onSignal: signal => f.agent.fireProgress(signal), onSessionCreated: f.bindNativeSession, + canvasEvents: [ + { ...metadata, type: 'user.message', id: 'native-before-return', data: { content: 'Initialize with a native tool' } }, + { ...metadata, type: 'tool.execution_start', id: 'tool-start', data: { toolCallId: 'native-tool', toolName: 'bash', arguments: { command: 'echo hello' } } }, + ], + beforeSdkReturn: async (runtime, _session, emit) => { + permission = await runtime.handlePermissionRequest(toPermissionRequest({ + kind: 'shell', fullCommandText: 'echo hello', toolCallId: 'native-tool', managedApprovalRequired: true, + })); + assert.strictEqual(f.state.getSnapshot(peer), undefined); + emit({ ...metadata, type: 'tool.execution_complete', id: 'tool-complete', data: { toolCallId: 'native-tool', success: true, result: { content: 'hello' } } }); + emit({ ...metadata, type: 'session.idle', id: 'idle-before-return', ephemeral: true, data: {} }); + }, + }); + f.state.addChat(canvasSession, peer); + lease.commit(); + lease.dispose(); + await timeout(0); + assert.deepStrictEqual({ + permission, approvals, input: f.state.getChatState(peer)?.turns.map(turn => turn.message.text), + tools: f.state.getChatState(peer)?.turns.flatMap(turn => turn.responseParts.flatMap(part => part.kind === ResponsePartKind.ToolCall ? [[part.toolCall.toolCallId, part.toolCall.status]] : [])), + sends: native.mockSession.sendRequests, mainTurns: f.state.getChatState(canvasChat)?.turns, + }, { permission: { kind: 'approve-once' }, approvals: [peer], input: ['Initialize with a native tool'], tools: [['native-tool', ToolCallStatus.Completed]], sends: [], mainTurns: [] }); + }); + + test('extension context uses public sendMessages and steering keeps its mode on the outer request', async () => { + const chat = URI.parse(buildChatUri('copilotcli:/test-session-1', 'peer')); + const { session, mockSession } = await createAgentSession(disposables, { chatChannelUri: chat }); + const context = { type: 'extension_context', extensionId: 'project:counter', canvasId: 'counter', instanceId: 'main', title: 'Counter state', capturedAt: '2026-01-01T00:00:00Z', payload: { count: 7 } } as const; + const attachment = extensionContextToProtocol(context, chat.toString()); + await session.send('Use this state', [attachment]); + await session.sendSteering({ id: 'steer', message: { text: 'And this state', origin: { kind: MessageKind.User }, attachments: [attachment] } }); + assert.deepStrictEqual([mockSession.sendRequests, mockSession.sendMessagesRequests], [[], [ + { messages: [{ prompt: 'Use this state', attachments: [context] }] }, + { messages: [{ prompt: 'And this state', attachments: [context] }], mode: 'immediate' }, + ]]); + }); + + test('extension-context metadata cannot acquire another chat routing identity', async () => { + const { session, mockSession } = await createAgentSession(disposables); + const attachment = extensionContextToProtocol({ type: 'extension_context', extensionId: 'project:counter', title: 'State', capturedAt: '2026-01-01T00:00:00Z', payload: { count: 1 } }, buildChatUri('copilotcli:/other', 'default')); + await session.send('User-supplied text', [attachment]); + assert.deepStrictEqual([mockSession.sendMessagesRequests.length, mockSession.sendRequests.length], [0, 1]); + }); + test('forwards an embedded resource with a selection as its already-sliced inline blob', async () => { const { session, mockSession } = await createAgentSession(disposables); diff --git a/src/vs/platform/agentHost/test/node/copilotCanvases.test.ts b/src/vs/platform/agentHost/test/node/copilotCanvases.test.ts new file mode 100644 index 0000000000000..2760823655b41 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotCanvases.test.ts @@ -0,0 +1,696 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { realpath } from 'fs/promises'; +import type { CopilotClient, CopilotSession, ExtensionLaunchProviderResolveRequest, SessionEvent, SessionEventPayload, SessionEventType } from '@github/copilot-sdk'; +import { DeferredPromise, raceCancellationError, timeout } from '../../../../base/common/async.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 type { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IAgentCanvasOperation } from '../../common/agentHostCanvases.js'; +import { isCanvasSessionRetained } from '../../common/meta/agentCanvasSessionMeta.js'; +import { CanvasAvailabilityStatus, CanvasTrustStatus, type CanvasState } from '../../common/state/protocol/channels-canvas/state.js'; +import type { OpenCanvasParams } from '../../common/state/protocol/channels-canvas/commands.js'; +import type { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; +import { CopilotCanvases, type ICopilotCanvasHost } from '../../node/copilot/copilotCanvases.js'; +import { CopilotSessionEventBuffer, CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; +import { unavailableCanvases } from '../common/agentHostCanvasesTestUtils.js'; +import { canvasChat, canvasIdentity, canvasSession, createCanvasHostServices, createCanvasSession } from './agentHostCanvasTestUtils.js'; +import { MockAgent } from './mockAgent.js'; + +type NativeCanvas = Awaited>['canvases'][number]; +type NativeInstance = Awaited>['openCanvases'][number]; + +const nativeIdentity = { extensionId: 'project:counter', canvasId: 'counter', instanceId: 'main' }; +const endpoint = 'http://127.0.0.1:8123/app?transient=not-persisted'; +const openParams: OpenCanvasParams = { channel: canvasSession, canvas: 'ahp-canvas:/native', identity: canvasIdentity, title: 'Counter', requestId: 'open' }; + +function nativeEvent(type: K, data: SessionEventPayload['data']): SessionEventPayload { + // The public SDK event union is discriminated by the supplied type. + return { type, data, id: 'event', timestamp: '2026-01-01T00:00:00Z', parentId: null, ...(type === 'user.message' ? {} : { ephemeral: true }) } as SessionEventPayload; +} + +function createFixture(store: Pick, canvases?: IAgentHostCanvasesService) { + const calls: string[] = []; + const approvals: Array<{ chat: string; message: string; clientId: string | undefined }> = []; + const attachments: Array<{ chat: string; count: number }> = []; + let approve = async () => true; + let retainGate = Promise.resolve(); + let retainResponse = 'null'; + let listGate = Promise.resolve(); + let listOpenGate = Promise.resolve(); + let openGate = Promise.resolve(); + let openEndpoint = endpoint; + const openInputs: Parameters[0][] = []; + let closeGate = Promise.resolve(); + let connected = true; + let busy = false; + let onPrepare = async () => { }; + let onRecover = async () => { }; + const events = store.add(new Emitter()); + let catalog: NativeCanvas[] = [{ ...nativeIdentity, displayName: 'Counter', description: 'Counter', actions: [{ name: 'increment' }] }]; + let instances: NativeInstance[] = [{ ...nativeIdentity, title: 'Counter', url: endpoint }]; + const rpc = new class extends mock() { + override readonly canvas = { + list: async () => { calls.push('list'); const canvases = catalog; await listGate; return { canvases }; }, + listOpen: async () => { calls.push('listOpen'); const openCanvases = instances; await listOpenGate; return { openCanvases }; }, + open: async (params: Parameters[0]) => { + calls.push('open'); + openInputs.push(params); + await openGate; + assert.ok(params.extensionId); + return { ...params, extensionId: params.extensionId, url: openEndpoint }; + }, + close: async () => { calls.push('close'); await closeGate; }, + action: { invoke: async () => { calls.push('invoke'); return { count: 1 }; } }, + }; + }(); + const session = new class extends mock() { + override readonly sessionId = 'native-session'; + override get rpc() { return rpc; } + override on(handler: (event: SessionEvent) => void): () => void; + override on(type: K, handler: (event: SessionEventPayload) => void): () => void; + override on(type: K | ((event: SessionEvent) => void), handler?: (event: SessionEventPayload) => void): () => void { + const listener = typeof type === 'function' + ? events.event(type) + : Event.filter, SessionEvent>(events.event, (event): event is SessionEventPayload => event.type === type)(event => handler?.(event)); + return () => listener.dispose(); + } + override async disconnect(): Promise { calls.push('disconnect'); } + }(); + const clientRpc = new class extends mock() { + override readonly session = new class extends mock() { + override retain = async (params: Parameters[0]): Promise => { + calls.push(`retain:${params.sessionId}`); + await retainGate; + return JSON.parse(retainResponse); + }; + }(); + }(); + const client = new class extends mock() { + override get rpc() { + if (!connected) { + throw new Error('Client is not connected.'); + } + return clientRpc; + } + }(); + const host: ICopilotCanvasHost = { + prepare: async () => { calls.push('prepare'); await onPrepare(); }, + isBusy: () => busy, + residentChats: () => [canvasChat], + recoverOwnedRuntime: async () => { calls.push('recover'); await onRecover(); }, + }; + const adapter = store.add(new CopilotCanvases(host, canvases ?? { + ...unavailableCanvases, + requestApproval: async (chat, message, token, clientId) => { + approvals.push({ chat, message, clientId }); + return raceCancellationError(approve(), token); + }, + appendAttachments: (chat, values) => attachments.push({ chat, count: values.length }), + })); + const operation: IAgentCanvasOperation = { token: CancellationToken.None, willExecute: () => calls.push('effect'), clientId: 'origin-client' }; + const request: ExtensionLaunchProviderResolveRequest = { + source: 'project', id: 'project:counter', name: 'counter', modulePath: URI.parse(import.meta.url).fsPath, + sessionId: session.sessionId, defaultLaunch: { executable: process.execPath, args: ['unchanged-bootstrap'], env: { ORIGINAL: 'preserved' } }, + }; + const wrapper = store.add(new CopilotSessionWrapper(session)); + const start = () => { adapter.clientStarting(client); adapter.clientStarted(client); }; + const bind = (startup: 'complete' | 'pending' = 'complete') => { + const launch = store.add(adapter.beginLaunch(session.sessionId, canvasChat)); + if (startup === 'complete') { + launch.onEvent(nativeEvent('session.extensions_loaded', { extensions: [{ id: request.id, name: request.name, source: request.source, status: 'running' }] })); + } + return launch; + }; + const admit = () => adapter.launchProvider.resolve(request); + const state = (): CanvasState => { + const instance = adapter.getSnapshot(canvasChat)?.instances[0]; + assert.ok(instance); + return { resource: openParams.canvas, ...instance, identity: { ...instance.identity, incarnation: 'incarnation' }, trust: { status: CanvasTrustStatus.Trusted }, revision: 1 }; + }; + return { + adapter, client, session, events, wrapper, operation, request, calls, approvals, attachments, openInputs, start, bind, admit, state, + setApproval: (value: () => Promise) => { approve = value; }, + setRetainGate: (value: Promise) => { retainGate = value; }, + setRetainResponse: (value: string) => { retainResponse = value; }, + setListGate: (value: Promise) => { listGate = value; }, + setListOpenGate: (value: Promise) => { listOpenGate = value; }, + setOpenGate: (value: Promise) => { openGate = value; }, + setOpenEndpoint: (value: string) => { openEndpoint = value; }, + setCloseGate: (value: Promise) => { closeGate = value; }, + setCatalog: (value: NativeCanvas[]) => { catalog = value; }, + setInstances: (value: NativeInstance[]) => { instances = value; }, + setConnected: (value: boolean) => { connected = value; }, + setBusy: (value: boolean) => { busy = value; }, + setPrepare: (value: () => Promise) => { onPrepare = value; }, + setRecover: (value: () => Promise) => { onRecover = value; }, + }; +} + +suite('Copilot canvases', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('support requires completed launch-provider startup; cached reads never start it', async () => { + const f = createFixture(store); + f.adapter.clientStarting(f.client); + assert.deepStrictEqual([f.adapter.available, f.adapter.getSnapshot(canvasChat), await f.admit(), f.calls], [false, undefined, { launch: null }, []]); + f.adapter.clientStarted(f.client); + assert.strictEqual(f.adapter.available, true); + }); + + test('admission binds the exact chat and retains before returning the original recipe', async () => { + const f = createFixture(store); + f.start(); + f.bind(); + const retained = new DeferredPromise(); + f.setRetainGate(retained.p); + let settled = false; + const result = f.admit().then(value => { settled = true; return value; }); + while (!f.calls.length) { + await timeout(0); + } + assert.deepStrictEqual([settled, f.calls, f.approvals[0].chat], [false, ['retain:native-session'], canvasChat]); + await retained.complete(); + assert.strictEqual((await result).launch, f.request.defaultLaunch); + assert.match(f.approvals[0].message, /mutable-directory trust.*unsandboxed.*separate from Workspace Trust/); + }); + + test('initialization is not ready before the complete registry returns and never reloads a ready backing', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + const listed = new DeferredPromise(); + f.setListGate(listed.p); + const attach = launch.attach(f.wrapper); + assert.strictEqual(f.adapter.getSnapshot(canvasChat), undefined); + await listed.complete(); + await attach; + const effects = [...f.calls]; + await f.adapter.initializeChat(canvasChat, f.operation); + await f.adapter.initializeChat(canvasChat, f.operation); + assert.deepStrictEqual({ calls: f.calls, types: f.adapter.getSnapshot(canvasChat)?.types.length }, { calls: effects, types: 1 }); + }); + + for (const query of ['list', 'listOpen'] as const) { + test(`startup completion is followed by a complete ${query} before registry readiness`, async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind('pending'); + await f.admit(); + const listed = new DeferredPromise(); + if (query === 'list') { + f.setListGate(listed.p); + } else { + f.setListOpenGate(listed.p); + } + let settled = false; + const attached = launch.attach(f.wrapper).then(() => { settled = true; }); + await timeout(0); + const beforeStartup = { settled, snapshot: f.adapter.getSnapshot(canvasChat), calls: [...f.calls] }; + launch.onEvent(nativeEvent('session.extensions_loaded', { + extensions: [{ id: f.request.id, name: f.request.name, source: f.request.source, status: 'running' }], + })); + await timeout(0); + const duringQuery = { settled, snapshot: f.adapter.getSnapshot(canvasChat), calls: [...f.calls] }; + await listed.complete(); + await attached; + assert.deepStrictEqual({ + beforeStartup, duringQuery, settled, types: f.adapter.getSnapshot(canvasChat)?.types.length, + }, { + beforeStartup: { settled: false, snapshot: undefined, calls: ['retain:native-session'] }, + duringQuery: { settled: false, snapshot: undefined, calls: ['retain:native-session', 'list', 'listOpen'] }, + settled: true, types: 1, + }); + }); + + test(`the original ${query} failure survives startup completion`, async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + const listed = new DeferredPromise(); + if (query === 'list') { + f.setListGate(listed.p); + } else { + f.setListOpenGate(listed.p); + } + const failure = new Error('Native registry query failed'); + const rejected = assert.rejects(launch.attach(f.wrapper), error => error === failure); + await timeout(0); + await listed.error(failure); + await rejected; + assert.strictEqual(f.adapter.getSnapshot(canvasChat), undefined); + }); + } + + for (const cancellation of ['launch', 'wrapper', 'authority'] as const) { + test(`${cancellation} cancellation fences a late extension startup completion`, async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind('pending'); + const rejected = assert.rejects(launch.attach(f.wrapper), isCancellationError); + switch (cancellation) { + case 'launch': launch.dispose(); break; + case 'wrapper': f.wrapper.dispose(); break; + case 'authority': f.adapter.loseAuthority(); break; + } + await rejected; + launch.onEvent(nativeEvent('session.extensions_loaded', { extensions: [] })); + assert.deepStrictEqual({ + cancelled: launch.token.isCancellationRequested, snapshot: f.adapter.getSnapshot(canvasChat), + calls: f.calls, authorityLost: f.adapter.authorityLost, + }, { + cancelled: true, snapshot: undefined, + calls: cancellation === 'wrapper' ? ['disconnect'] : [], authorityLost: cancellation === 'authority', + }); + }); + } + + for (const command of ['initialize', 'open'] as const) { + test(`host ${command} keeps source approvals authoritative after the SDK handle returns and before startup completes`, async () => { + const services = createCanvasHostServices(store); + const f = createFixture(store, services.service); + services.providers.registerProvider(new class extends MockAgent { + readonly canvases = f.adapter; + }('copilot')); + createCanvasSession(services.state); + store.add(services.connections.registerSource({ + hasSeenClient: () => true, isClientConnected: () => true, + getConnectedClientTransportCounts: () => new Map([['owner', 1]]), getSubscribedClients: () => ['owner'], + requestWorkspaceTrust: async () => false, requestCanvasApproval: async () => { throw new Error('Use the original physical transport.'); }, + })); + const entered = new DeferredPromise(); + const approved = new DeferredPromise(); + const connection = store.add(services.service.connect('owner', async () => { + await entered.complete(); + return approved.p; + })); + f.start(); + f.setCatalog([]); + f.setInstances([]); + const materialized = new DeferredPromise<{ launch: ReturnType; resolving: ReturnType }>(); + f.setPrepare(async () => { + const launch = f.bind('pending'); + const resolving = f.admit(); + await entered.p; + const attached = launch.attach(f.wrapper); + await materialized.complete({ launch, resolving }); + await attached; + }); + const initializing = command === 'initialize' + ? connection.initializeCanvasChat({ channel: canvasChat, requestId: command }) + : connection.openCanvas({ ...openParams, requestId: command }); + const outcome = initializing.then(() => 'complete', () => 'rejected'); + const { launch, resolving } = await materialized.p; + await timeout(0); + const early = { + snapshot: f.adapter.getSnapshot(canvasChat), + initializing: services.service.isChatInitializing(canvasChat), + }; + await approved.complete(true); + const result = await resolving; + const granted = result.launch === f.request.defaultLaunch; + if (granted) { + const declaration: NativeCanvas = { ...nativeIdentity, displayName: 'Counter', description: '', actions: [] }; + f.setCatalog([declaration]); + launch.onEvent(nativeEvent('session.canvas.registry_changed', { canvases: [declaration] })); + } + launch.onEvent(nativeEvent('session.extensions_loaded', { + extensions: [{ id: f.request.id, name: f.request.name, source: f.request.source, status: granted ? 'running' : 'failed' }], + })); + if (!granted) { + connection.dispose(); + } + const settled = await outcome; + const session = services.state.getSessionState(canvasSession)!; + const observer = store.add(services.service.connect('reader')); + assert.deepStrictEqual({ + early, granted, settled, retained: isCanvasSessionRetained(session), + turns: session.turns, members: services.state.getChatCanvasStates(canvasChat).length, + types: (await observer.listCanvasTypes({ channel: canvasChat })).types.length, + initializing: services.service.isChatInitializing(canvasChat), + }, { + early: { snapshot: undefined, initializing: true }, granted: true, settled: 'complete', retained: true, + turns: [], members: command === 'open' ? 1 : 0, types: 1, initializing: false, + }); + }); + } + + test('same-identity reopen forwards new input and replaces the effective endpoint', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + await f.adapter.open({ ...openParams, input: { revision: 1 } }, f.operation); + const firstGeneration = f.adapter.getSnapshot(canvasChat)?.instances[0].generation; + f.setOpenEndpoint('http://127.0.0.1:8123/reopened?revision=2'); + await f.adapter.open({ ...openParams, input: { revision: 2 }, requestId: 'open-again' }, f.operation); + const source = await f.adapter.resolve(f.state(), 'client', CancellationToken.None); + assert.deepStrictEqual({ + inputs: f.openInputs.map(input => input.input), instances: f.adapter.getSnapshot(canvasChat)?.instances.length, + changed: f.adapter.getSnapshot(canvasChat)?.instances[0].generation !== firstGeneration, source, + }, { inputs: [{ revision: 1 }, { revision: 2 }], instances: 1, changed: true, source: { url: 'http://127.0.0.1:8123/reopened?revision=2' } }); + }); + + test('native file presentation resolves canonically without granting trusted-file authority or calling the runtime', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + const file = new URL(import.meta.url); + file.host = 'localhost'; + file.search = '?view=two'; + file.hash = '#counter'; + f.setInstances([{ ...nativeIdentity, title: 'File', url: file.toString() }]); + await launch.attach(f.wrapper); + const calls = [...f.calls]; + const source = await f.adapter.resolve(f.state(), 'client', CancellationToken.None); + const canonical = URI.file(await realpath(URI.parse(import.meta.url).fsPath)).toString(); + assert.deepStrictEqual({ + source, identitySource: f.state().identity.source, calls: f.calls, + }, { source: { url: `${canonical}?view=two#counter` }, identitySource: canvasIdentity.source, calls }); + }); + + test('non-null retention responses cannot authorize top-level execution', async () => { + const f = createFixture(store); + f.start(); + f.bind(); + const launches = []; + for (const response of ['false', '{}', '[]', '0', '""']) { + f.setRetainResponse(response); + launches.push((await f.admit()).launch); + } + assert.deepStrictEqual(launches, [null, null, null, null, null]); + }); + + test('denial, wrong session, relative source, namespace mismatch and racing IDs cannot launch', async () => { + const f = createFixture(store); + f.start(); + f.bind(); + f.setApproval(async () => false); + const denied = await f.admit(); + const wrong = await f.adapter.launchProvider.resolve({ ...f.request, sessionId: 'other-chat' }); + const relative = await f.adapter.launchProvider.resolve({ ...f.request, modulePath: 'relative.js' }); + const namespace = await f.adapter.launchProvider.resolve({ ...f.request, id: 'user:counter' }); + const approval = new DeferredPromise(); + f.setApproval(() => approval.p); + const pending = f.admit(); + const duplicate = await f.admit(); + await approval.complete(false); + assert.deepStrictEqual([denied, wrong, relative, namespace, duplicate, await pending, f.calls], [ + { launch: null }, { launch: null }, { launch: null }, { launch: null }, { launch: null }, { launch: null }, [], + ]); + }); + + test('SDK cancellation prevents late consent and retention', async () => { + const f = createFixture(store); + f.start(); + f.bind(); + const cancellation = store.add(new CancellationTokenSource()); + const approval = new DeferredPromise(); + f.setApproval(() => approval.p); + const result = f.adapter.launchProvider.resolve(f.request, cancellation.token); + while (!f.approvals.length) { + await timeout(0); + } + cancellation.cancel(); + await assert.rejects(result, /Canceled/); + await approval.complete(true); + assert.deepStrictEqual(f.calls, []); + }); + + test('canvas-first cancellation retires pending source admission without a synthetic turn', async () => { + const f = createFixture(store); + f.start(); + const cancellation = store.add(new CancellationTokenSource()); + const approval = new DeferredPromise(); + f.setApproval(() => approval.p); + f.setPrepare(async () => { f.bind(); await f.admit(); }); + const prepared = f.adapter.prepare(canvasIdentity, { ...f.operation, token: cancellation.token }); + while (!f.approvals.length) { + await timeout(0); + } + cancellation.cancel(); + await assert.rejects(prepared, /Canceled/); + await approval.complete(true); + assert.deepStrictEqual([f.approvals[0].clientId, f.calls, f.adapter.getSnapshot(canvasChat)], ['origin-client', ['effect', 'prepare'], undefined]); + }); + + test('environment consent names an admitted original source and exact variables, without toolCallId', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + const approved = await launch.permission({ kind: 'extension-env-access', extensionName: 'project:counter', environmentVariables: ['PROJECT_TOKEN'] }); + const rejected = await launch.permission({ kind: 'extension-env-access', extensionName: 'user:counter', environmentVariables: ['PROJECT_TOKEN'] }); + const invalidName = await launch.permission({ kind: 'extension-env-access', extensionName: 'project:counter', environmentVariables: ['NOT-A-NAME'] }); + assert.deepStrictEqual([approved, rejected, invalidName, f.approvals.map(value => value.chat)], [{ kind: 'approve-once' }, { kind: 'reject' }, { kind: 'reject' }, [canvasChat, canvasChat]]); + assert.match(f.approvals[1].message, /project:counter.*PROJECT_TOKEN.*Values are never included/); + }); + + test('native opens are observations, expose live actions and never trigger a second open', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + const state = f.state(); + const source = await f.adapter.resolve(state, 'client', CancellationToken.None); + assert.deepStrictEqual([state.availability, source, f.calls], [ + { status: CanvasAvailabilityStatus.Ready, actions: [{ id: 'increment' }] }, { url: endpoint }, ['retain:native-session', 'list', 'listOpen'], + ]); + assert.ok(!JSON.stringify(f.adapter.getSnapshot(canvasChat)).includes(endpoint)); + }); + + test('early close fences a stale listOpen result', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + const listed = new DeferredPromise(); + f.setListGate(listed.p); + const attached = launch.attach(f.wrapper); + launch.onEvent(nativeEvent('session.canvas.closed', nativeIdentity)); + await listed.complete(); + await attached; + assert.deepStrictEqual(f.adapter.getSnapshot(canvasChat)?.instances, []); + }); + + test('a close during open does not publish the stale RPC completion', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + const gate = new DeferredPromise(); + f.setOpenGate(gate.p); + const opened = f.adapter.open(openParams, f.operation); + launch.onEvent(nativeEvent('session.canvas.closed', nativeIdentity)); + await gate.complete(); + await assert.rejects(opened, /closed while/); + assert.deepStrictEqual(f.adapter.getSnapshot(canvasChat)?.instances, []); + }); + + test('close and recreation rotate individual endpoint identity without replacing the backing', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + const before = f.adapter.getSnapshot(canvasChat)!; + launch.onEvent(nativeEvent('session.canvas.closed', nativeIdentity)); + launch.onEvent(nativeEvent('session.canvas.opened', { ...nativeIdentity, url: endpoint })); + const after = f.adapter.getSnapshot(canvasChat)!; + assert.deepStrictEqual([after.generation === before.generation, after.instances[0].generation === before.instances[0].generation], [true, false]); + }); + + test('close ACK cannot delete a replacement instance', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + const gate = new DeferredPromise(); + f.setCloseGate(gate.p); + const closed = f.adapter.close(f.state(), f.operation); + launch.onEvent(nativeEvent('session.canvas.closed', nativeIdentity)); + launch.onEvent(nativeEvent('session.canvas.opened', { ...nativeIdentity, title: 'Replacement', url: endpoint })); + await gate.complete(); + await assert.rejects(closed, /different native instance/); + assert.strictEqual(f.state().title, 'Replacement'); + }); + + test('coalesced close and cross-type open retain both full canonical identities', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + f.setCatalog([ + { ...nativeIdentity, displayName: 'Counter', description: '' }, + { ...nativeIdentity, canvasId: 'triage', displayName: 'Triage', description: '' }, + ]); + await launch.attach(f.wrapper); + launch.onEvent(nativeEvent('session.canvas.closed', nativeIdentity)); + launch.onEvent(nativeEvent('session.canvas.opened', { ...nativeIdentity, canvasId: 'triage', url: endpoint })); + const snapshot = f.adapter.getSnapshot(canvasChat); + assert.deepStrictEqual({ + closed: snapshot?.closed?.map(identity => identity.canvasType), + open: snapshot?.instances.map(instance => instance.identity.canvasType), + }, { closed: ['counter'], open: ['triage'] }); + }); + + test('source revocation and public connection loss invalidate pulls without restart or replacement', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + const state = f.state(); + f.setConnected(false); + const source = await f.adapter.resolve(state, 'client', CancellationToken.None); + assert.deepStrictEqual([f.adapter.available, f.adapter.authorityLost, source, f.calls], [false, true, undefined, ['retain:native-session', 'list', 'listOpen']]); + assert.throws(() => f.adapter.clientStarting(f.client), /Explicit owned-runtime recovery/); + }); + + test('schema references are bounded, immutable, generation- and source-bound', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + const schema = { type: 'object', properties: Object.fromEntries(Array.from({ length: 65 }, (_, index) => [`p${index}`, { type: 'string' }])) }; + const catalog: NativeCanvas[] = [{ ...nativeIdentity, displayName: 'Counter', description: '', inputSchema: schema }]; + f.setCatalog(catalog); + await launch.attach(f.wrapper); + const reference = f.adapter.getSnapshot(canvasChat)?.types[0].openInputSchemaRef; + assert.ok(reference && reference.length < 128); + const resolved = await f.adapter.resolveSchema(canvasChat, canvasIdentity.source, reference); + launch.onEvent(nativeEvent('session.canvas.registry_changed', { canvases: [{ ...catalog[0], inputSchema: { ...schema, required: ['p0'] } }] })); + assert.deepStrictEqual([resolved, await f.adapter.resolveSchema(canvasChat, canvasIdentity.source, reference), await f.adapter.resolveSchema(`${canvasChat}/other`, canvasIdentity.source, reference)], [schema, schema, undefined]); + assert.notStrictEqual(f.adapter.getSnapshot(canvasChat)?.types[0].openInputSchemaRef, reference); + launch.onEvent(nativeEvent('session.extensions_loaded', { extensions: [{ id: nativeIdentity.extensionId, name: 'counter', source: 'project', status: 'disabled' }] })); + assert.deepStrictEqual([ + await f.adapter.resolveSchema(canvasChat, canvasIdentity.source, reference), + await f.adapter.resolve({ resource: openParams.canvas, identity: { ...canvasIdentity, incarnation: 'old' }, title: 'Counter', availability: { status: CanvasAvailabilityStatus.NotLoaded }, trust: { status: CanvasTrustStatus.Pending }, revision: 1 }, 'client', CancellationToken.None), + ], [undefined, undefined]); + }); + + test('live registry metadata and retained schema references have aggregate bounds', async () => { + for (const oversizedEvent of [false, true]) { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + for (let index = 0; index < (oversizedEvent ? 1 : 9); index++) { + launch.onEvent(nativeEvent('session.canvas.registry_changed', { + canvases: [{ + ...nativeIdentity, displayName: 'Counter', description: oversizedEvent ? 'x'.repeat(8 * 1024 * 1024) : '', + inputSchema: { type: 'object', properties: Object.fromEntries(Array.from({ length: 65 }, (_, property) => [`p${property}`, { type: 'string' }])), description: `${index}${'x'.repeat(950_000)}` }, + }], + })); + } + assert.deepStrictEqual([f.adapter.getSnapshot(canvasChat)?.instances[0].availability.status, f.adapter.getTrust(canvasChat, canvasIdentity.source).status], [CanvasAvailabilityStatus.Failed, CanvasTrustStatus.Blocked]); + } + }); + + test('pushed attachments stay on their owning chat and reject unadmitted or oversized contexts', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + const context = { type: 'extension_context', extensionId: nativeIdentity.extensionId, title: 'Counter state', capturedAt: '2026-01-01T00:00:00Z', payload: { count: 1 } } as const; + launch.onEvent(nativeEvent('session.extensions.attachments_pushed', { attachments: [context] })); + await f.admit(); + launch.onEvent(nativeEvent('session.extensions.attachments_pushed', { attachments: [context] })); + launch.onEvent(nativeEvent('session.extensions.attachments_pushed', { attachments: [{ ...context, extensionId: 'user:other' }] })); + launch.onEvent(nativeEvent('session.extensions.attachments_pushed', { attachments: [{ ...context, payload: { content: 'x'.repeat(65_537) } }] })); + assert.deepStrictEqual(f.attachments, [{ chat: canvasChat, count: 1 }]); + }); + + test('lost launch authority requires an explicit owned-runtime restart and never replays an action', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + const state = f.state(); + f.adapter.loseAuthority(); + f.calls.length = 0; + f.setApproval(async () => false); + await assert.rejects(f.adapter.restart(state, f.operation), /not approved/); + assert.deepStrictEqual(f.calls, []); + f.setApproval(async () => true); + f.setRecover(async () => f.start()); + f.setPrepare(async () => { + const replacement = f.bind(); + await f.admit(); + await replacement.attach(f.wrapper); + }); + await f.adapter.restart(state, f.operation); + assert.deepStrictEqual([f.adapter.available, f.calls], [true, ['effect', 'recover', 'effect', 'prepare', 'retain:native-session', 'list', 'listOpen']]); + assert.match(f.approvals[1].message, /disconnects 1 resident chats.*No canvas action or model turn will be replayed/); + }); + + test('invalid current declarations retire ready endpoints instead of preserving stale actions', async () => { + const f = createFixture(store); + f.start(); + const launch = f.bind(); + await f.admit(); + await launch.attach(f.wrapper); + launch.onEvent(nativeEvent('session.canvas.registry_changed', { canvases: [{ ...nativeIdentity, displayName: 'Counter', description: '', actions: [{ name: 'duplicate' }, { name: 'duplicate' }] }] })); + assert.deepStrictEqual([f.adapter.getSnapshot(canvasChat)?.instances[0].availability.status, f.adapter.getTrust(canvasChat, canvasIdentity.source).status], [CanvasAvailabilityStatus.Failed, CanvasTrustStatus.Blocked]); + }); + + test('early live SDK messages and assistant events replay once after handlers exist', () => { + const f = createFixture(store); + const buffer = new CopilotSessionEventBuffer(); + const first = nativeEvent('user.message', { content: 'Native message' }); + buffer.capture(first); + const wrapper = store.add(new CopilotSessionWrapper(f.session, buffer)); + const received: string[] = []; + store.add(wrapper.onUserMessage(event => received.push(event.data.content))); + store.add(wrapper.onMessageDelta(event => received.push(event.data.deltaContent))); + const delta = nativeEvent('assistant.message_delta', { messageId: 'response', deltaContent: 'Early response' }); + buffer.capture(delta); + f.events.fire(delta); + assert.deepStrictEqual(received, []); + wrapper.releaseBufferedEvents(); + wrapper.releaseBufferedEvents(); + assert.deepStrictEqual(received, ['Native message', 'Early response']); + }); + + test('early event overflow fails explicitly rather than silently losing a turn', () => { + const f = createFixture(store); + const buffer = new CopilotSessionEventBuffer(); + for (let i = 0; i < 1025; i++) { + buffer.capture(nativeEvent('user.message', { content: 'message' })); + } + const wrapper = store.add(new CopilotSessionWrapper(f.session, buffer)); + assert.throws(() => wrapper.releaseBufferedEvents(), /bounded buffer/); + }); + + test('pending wrappers deliver early events once and disconnect late SDK objects after disposal', async () => { + const f = createFixture(store); + const pending = store.add(new CopilotSessionWrapper(f.session.sessionId)); + const received: string[] = []; + store.add(pending.onUserMessage(event => received.push(event.data.content))); + pending.acceptSessionEvent(nativeEvent('user.message', { content: 'Before create completed' })); + await pending.attachSession(f.session); + f.events.fire(nativeEvent('user.message', { content: 'After create completed' })); + const cancelled = store.add(new CopilotSessionWrapper(f.session.sessionId)); + cancelled.dispose(); + await assert.rejects(cancelled.attachSession(f.session), /Canceled/); + assert.deepStrictEqual({ + received, ready: await pending.whenReady === f.session, + cancelled: await cancelled.whenReady, calls: f.calls, + }, { received: ['Before create completed', 'After create completed'], ready: true, cancelled: undefined, calls: ['disconnect'] }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts index cb89ceef7f05e..7daf8603d7fce 100644 --- a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -112,6 +112,7 @@ function createTestLauncher(managedSettingsPermissions?: IAgentHostManagedSettin setSessionSandboxPolicy: () => { }, } as Partial as IAgentConfigurationService; return new CopilotSessionLauncher( + undefined, configurationService, { permissions: managedSettingsPermissions ?? {} } as IAgentHostManagedSettingsService, {} as IAgentHostTerminalManager, @@ -480,7 +481,7 @@ suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { // The launcher's other dependencies are unused by the BYOK path and // resolve to `undefined` under the non-strict InstantiationService. const instantiationService = store.add(new InstantiationService(services)); - return instantiationService.createInstance(CopilotSessionLauncher); + return instantiationService.createInstance(CopilotSessionLauncher, undefined); } test('memoizes the handle, and disposeByokProxyHandle releases it so the next launch mints a fresh nonce', async () => { @@ -570,6 +571,7 @@ suite('CopilotSessionLauncher shared session config', () => { test('passes Agent Host defaults, managed permissions, and exit-plan handler to create and resume', async () => { const createConfigs: Parameters[0][] = []; const resumeConfigs: Parameters[1][] = []; + const initialScriptSafety: (boolean | undefined)[] = []; const session = { sessionId: 'session-1', on: () => () => { }, @@ -578,11 +580,13 @@ suite('CopilotSessionLauncher shared session config', () => { } as unknown as CopilotSession; const client = { createSession: async (config: Parameters[0]) => { + initialScriptSafety.push(config.enableScriptSafety); reportManagedSettings(config); createConfigs.push(config); return session; }, resumeSession: async (_sessionId: string, config: Parameters[1]) => { + initialScriptSafety.push(config.enableScriptSafety); reportManagedSettings(config); resumeConfigs.push(config); return session; @@ -680,6 +684,7 @@ suite('CopilotSessionLauncher shared session config', () => { sessions.add(await launcher.launch({ ...createPlan, isEphemeral: true }, testRuntime)); assert.deepStrictEqual({ + initialScriptSafety, createClientName: createConfigs[0].clientName, createGitHubMcpToolConfig: createConfigs[0].githubMcpToolConfig, createPluginDirectories: createConfigs[0].pluginDirectories, @@ -716,6 +721,7 @@ suite('CopilotSessionLauncher shared session config', () => { testWorkingDirectory.fsPath, ].filter(value => logService.traces.some(message => message.includes('MCP launch projection:') && message.includes(value))), }, { + initialScriptSafety: [true, true, true], createClientName: 'vscode-agent-host', createGitHubMcpToolConfig: { disableFormDeferral: true }, createPluginDirectories: [pluginDir.fsPath, syntheticPluginDir.fsPath], @@ -800,8 +806,9 @@ suite('CopilotSessionLauncher resume fallback', () => { } } - function createResumeFailingLaunch(message: string, code = -32603, sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry): { readonly launcher: CopilotSessionLauncher; readonly plan: CopilotSessionLaunchPlan; readonly getCreateSessionCalls: () => number } { + function createResumeFailingLaunch(message: string, code = -32603, sessionOpenTelemetry: IAgentHostSessionOpenTelemetry = noopSessionOpenTelemetry): { readonly launcher: CopilotSessionLauncher; readonly plan: CopilotSessionLaunchPlan; readonly getCreateSessionCalls: () => number; readonly initialScriptSafety: readonly (boolean | undefined)[] } { let createSessionCalls = 0; + const initialScriptSafety: (boolean | undefined)[] = []; const session = { sessionId: 'session-1', on: () => () => { }, @@ -810,11 +817,13 @@ suite('CopilotSessionLauncher resume fallback', () => { } as unknown as CopilotSession; const client = { createSession: async (config: ResumeSessionConfig) => { + initialScriptSafety.push(config.enableScriptSafety); reportManagedSettings(config); createSessionCalls++; return session; }, - resumeSession: async () => { + resumeSession: async (_sessionId: string, config: ResumeSessionConfig) => { + initialScriptSafety.push(config.enableScriptSafety); throw new TestSdkError(message, code); }, }; @@ -833,16 +842,17 @@ suite('CopilotSessionLauncher resume fallback', () => { fallback: { model: undefined }, }, getCreateSessionCalls: () => createSessionCalls, + initialScriptSafety, }; } test('falls back to createSession after a Start Over truncate leaves the session empty', async () => { - const { launcher, plan, getCreateSessionCalls } = createResumeFailingLaunch(`Request session.resume failed with message: LocalRpcSession: 'session.getMessages' returned no events for session session-1`); + const { launcher, plan, getCreateSessionCalls, initialScriptSafety } = createResumeFailingLaunch(`Request session.resume failed with message: LocalRpcSession: 'session.getMessages' returned no events for session session-1`); const sessions = new DisposableStore(); try { sessions.add(await launcher.launch(plan, testRuntime)); - assert.strictEqual(getCreateSessionCalls(), 1); + assert.deepStrictEqual({ createSessionCalls: getCreateSessionCalls(), initialScriptSafety }, { createSessionCalls: 1, initialScriptSafety: [true, true] }); } finally { sessions.dispose(); await launcher.disposeByokProxyHandle(); @@ -1465,7 +1475,7 @@ suite('CopilotSessionLauncher resume config', () => { // The launcher's other dependencies are unused by this path and resolve // to `undefined` under the non-strict InstantiationService. const instantiationService = store.add(new InstantiationService(services)); - return instantiationService.createInstance(CopilotSessionLauncher); + return instantiationService.createInstance(CopilotSessionLauncher, undefined); } /** Invokes the private config builder with a minimal resume plan. */ diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 025b7e9b9a8f9..0ff3abbf66cd6 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -4,7 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { unavailableCanvases } from '../common/agentHostCanvasesTestUtils.js'; +import { canvasChat, canvasIdentity, canvasSession, createCanvasServices, createCanvasSession } from './agentHostCanvasTestUtils.js'; +import type { IAgentHostCanvasesService } from '../../node/agentHostCanvasesService.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { hasKey } from '../../../../base/common/types.js'; @@ -17,14 +21,14 @@ import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.j import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agent.js'; import { type IAgentHostManagedSettingsDiagnostics, type IAgentHostNetworkDiagnosticsInfo, type IAgentHostNetworkFetchResult, type IAgentService } from '../../common/agentService.js'; -import { RemoveSessionArtifactExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, supportsAgentHostArtifactRemoval } from '../../common/agentHostExtensionProtocol.js'; +import { CancelAgentHostCanvasApprovalExtensionMethod, CancelCanvasChatInitializationExtensionMethod, InitializeCanvasChatExtensionMethod, RemoveSessionArtifactExtensionMethod, RequestAgentHostCanvasApprovalExtensionMethod, RequestAgentHostWorkspaceTrustExtensionMethod, supportsAgentHostArtifactRemoval, supportsAgentHostCanvasChatInitialization, type IAgentHostCanvasApprovalRequest, type IAgentHostExtensionCommandMap } from '../../common/agentHostExtensionProtocol.js'; import { ChatSourceKind, CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import type { AutomationCapabilities, Implementation } from '../../common/state/protocol/common/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../common/state/protocol/channels-automation/commands.js'; import { ActionType, type ActionEnvelope, type ChatAction, type ClientAnnotationsAction, type ClientAutomationAction, type ClientAutomationRunAction, type ClientChangesetAction, type IRootConfigChangedAction, type ProgressParams, 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, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; -import { AUTOMATION_CATALOG_URI, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type CommandMap, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot, type SubscribeResult } from '../../common/state/sessionProtocol.js'; +import { AUTOMATION_CATALOG_URI, ROOT_STATE_URI, MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; import type { SessionAddedParams, SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; @@ -224,7 +228,15 @@ class MockAgentService implements IAgentService { } async fetchAutomationRuns(_params: FetchAutomationRunsParams): Promise { return {}; } async getCompletionTriggerCharacters(): Promise { return []; } - async disposeSession(_session: URI): Promise { } + readonly disposedSessions: string[] = []; + disposeSessionError: Error | undefined; + async disposeSession(session: URI): Promise { + this.disposedSessions.push(session.toString()); + if (this.disposeSessionError) { + throw this.disposeSessionError; + } + this._stateManager.removeSession(session.toString()); + } readonly createdChats: { session: string; chat: string; options?: IAgentCreateChatRequestOptions }[] = []; readonly disposedChats: { session: string; chat: string }[] = []; async createChat(session: URI, chat: URI, options?: IAgentCreateChatRequestOptions): Promise { @@ -393,6 +405,7 @@ suite('ProtocolServerHandler', () => { let telemetryService: TestTelemetryService; let agentHostTelemetryService: AgentHostTelemetryService; let clientConnections: AgentHostClientConnectionService; + let canvasService: IAgentHostCanvasesService; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); const defaultChatUri = buildDefaultChatUri(sessionUri); @@ -436,6 +449,7 @@ suite('ProtocolServerHandler', () => { telemetryService = new TestTelemetryService(); agentHostTelemetryService = disposables.add(new AgentHostTelemetryService(telemetryService)); clientConnections = disposables.add(new AgentHostClientConnectionService()); + canvasService = unavailableCanvases; disposables.add(agentService); disposables.add(handler = new ProtocolServerHandler( agentService, @@ -447,6 +461,14 @@ suite('ProtocolServerHandler', () => { agentHostTelemetryService, managedSettingsService, clientConnections, + { + ...unavailableCanvases, + get available() { return canvasService.available; }, + get readiness() { return canvasService.readiness; }, + connect: (clientId, requestApproval) => canvasService.connect(clientId, requestApproval), + cancelChatInitialization: chat => canvasService.cancelChatInitialization(chat), + cancelSessionInitialization: session => canvasService.cancelSessionInitialization(session), + }, )); }); @@ -456,6 +478,293 @@ suite('ProtocolServerHandler', () => { ensureNoDisposablesAreLeakedInTestSuite(); + suite('canvas transport', () => { + let requestId = 100; + const openParams: CommandMap['openCanvas']['params'] = { channel: canvasSession, canvas: 'ahp-canvas:/wire', identity: canvasIdentity, title: 'Counter', requestId: 'open' }; + + function enableCanvases() { + const fixture = createCanvasServices(disposables, stateManager, clientConnections); + canvasService = fixture.service; + createCanvasSession(stateManager); + return fixture; + } + + type CanvasTransportCommands = CommandMap & Pick; + async function call(transport: MockProtocolTransport, method: M, params: CanvasTransportCommands[M]['params']): Promise { + const id = requestId++; + const response = waitForResponse(transport, id); + transport.simulateMessage(request(id, method, params)); + const message = await response; + assert.ok(isJsonRpcResponse(message)); + if (hasKey(message, { error: true })) { + throw new ProtocolError(message.error.code, message.error.message); + } + assert.ok(hasKey(message, { result: true })); + return message.result as CanvasTransportCommands[M]['result']; + } + + async function connect(clientId: string, canvases = true, version = PROTOCOL_VERSION, initialSubscriptions?: string[], transportKind = AgentHostTransportKind.WebSocket) { + const transport = disposables.add(new MockProtocolTransport(transportKind)); + server.simulateConnection(transport); + const initialized = await call(transport, 'initialize', { channel: ROOT_STATE_URI, clientId, protocolVersions: [version], capabilities: canvases ? { canvases: {} } : undefined, initialSubscriptions }); + return { transport, initialized }; + } + + for (const transportKind of [AgentHostTransportKind.WebSocket, AgentHostTransportKind.MessagePort]) { + test(`identity-free initialization is opt-in, cancellable and exact-transport bound over ${transportKind}`, async () => { + const f = enableCanvases(); + f.facet.initialized = false; + const first = await connect('same-client', true, PROTOCOL_VERSION, undefined, transportKind); + const second = await connect('same-client', true, PROTOCOL_VERSION, undefined, transportKind); + f.facet.onInitialize = async (chat, operation) => { + assert.strictEqual(await f.service.requestApproval(chat, 'Approve this initializer?', operation.token, operation.clientId, operation.initiator), true); + }; + const params = { channel: canvasChat, requestId: 'initialize' }; + assert.strictEqual(supportsAgentHostCanvasChatInitialization(second.initialized), true); + assert.deepStrictEqual(await call(second.transport, 'listCanvasTypes', { channel: canvasChat }), { types: [] }); + const initializing = call(second.transport, InitializeCanvasChatExtensionMethod, params); + while (!findRequest(second.transport.sent, RequestAgentHostCanvasApprovalExtensionMethod)) { + await timeout(0); + } + const reverse = findRequest(second.transport.sent, RequestAgentHostCanvasApprovalExtensionMethod)!; + const approval = reverse.params as IAgentHostCanvasApprovalRequest; + await call(first.transport, CancelCanvasChatInitializationExtensionMethod, params); + first.transport.simulateClose(); + assert.strictEqual(findRequest(first.transport.sent, RequestAgentHostCanvasApprovalExtensionMethod), undefined); + second.transport.simulateMessage({ jsonrpc: '2.0', id: reverse.id, result: { requestId: approval.requestId, approved: true } }); + await initializing; + assert.deepStrictEqual({ + chat: approval.chat, calls: f.facet.calls, turn: stateManager.getActiveTurnId(canvasChat), + members: stateManager.getChatCanvasStates(canvasChat), + }, { chat: canvasChat, calls: ['initialize'], turn: undefined, members: [] }); + }); + + test(`six typed routes share the negotiated handler over ${transportKind}`, async () => { + const fixture = enableCanvases(); + const { transport, initialized } = await connect('canvas-client', true, PROTOCOL_VERSION, undefined, transportKind); + const listed = await call(transport, 'listCanvasTypes', { channel: canvasChat }); + const opened = await call(transport, 'openCanvas', openParams); + const subscribed = await call(transport, 'subscribe', { channel: opened.canvas.resource }); + const source = await call(transport, 'resolveCanvasSource', { channel: opened.canvas.resource }); + const invoked = await call(transport, 'invokeCanvasAction', { channel: opened.canvas.resource, actionId: 'increment', incarnation: opened.canvas.identity.incarnation, requestId: 'invoke' }); + const restarted = await call(transport, 'restartCanvasProvider', { channel: opened.canvas.resource, incarnation: opened.canvas.identity.incarnation, requestId: 'restart' }); + const current = stateManager.getCanvasState(opened.canvas.resource)!; + const closed = await call(transport, 'closeCanvas', { channel: current.resource, revision: current.revision, requestId: 'close' }); + assert.deepStrictEqual({ + capability: initialized.canvases, types: listed.types.length, subscription: subscribed.snapshot?.resource, + source: source.source, result: invoked.result, restarted, closed, remaining: stateManager.getCanvasState(current.resource), + calls: fixture.facet.calls, + }, { + capability: {}, types: 1, subscription: openParams.canvas, + source: fixture.facet.resolveResult, result: { count: 1 }, restarted: null, closed: null, remaining: undefined, + calls: ['prepare', 'open', 'resolve:canvas-client', 'invoke', 'restart', 'close'], + }); + }); + } + + test('real MessagePort frames carry canvas initialization and all six routes while management methods stay disabled', async () => { + const fixture = enableCanvases(); + fixture.facet.initialized = false; + const ports = disposables.add(new MessagePortProtocolServer()); + disposables.add(new ProtocolServerHandler( + agentService, + stateManager, + ports, + { allowExtensionMethods: false }, + disposables.add(new AgentHostFileSystemProvider()), + logService, + agentHostTelemetryService, + managedSettingsService, + clientConnections, + fixture.service, + )); + const received: ProtocolMessage[] = []; + disposables.add(ports.listen('renderer', 'frame')(frame => received.push(JSON.parse(frame)))); + await ports.call('renderer', 'connect'); + const send = async (method: M, params: CanvasTransportCommands[M]['params']): Promise => { + const id = requestId++; + const response = new DeferredPromise(); + const listener = disposables.add(ports.listen('renderer', 'frame')(frame => { + const message: ProtocolMessage = JSON.parse(frame); + if (isJsonRpcResponse(message) && message.id === id) { + void response.complete(message); + } + })); + try { + await ports.call('renderer', 'send', JSON.stringify(request(id, method, params))); + const message = await response.p; + assert.ok(hasKey(message, { result: true }), JSON.stringify(message)); + return message.result as CanvasTransportCommands[M]['result']; + } finally { + listener.dispose(); + } + }; + const initialized = await send('initialize', { channel: ROOT_STATE_URI, clientId: 'framed', protocolVersions: [PROTOCOL_VERSION], capabilities: { canvases: {} } }); + const initialization = { channel: canvasChat, requestId: 'initialize' }; + await send(InitializeCanvasChatExtensionMethod, initialization); + await send(CancelCanvasChatInitializationExtensionMethod, initialization); + const shutdownRequest = requestId++; + await ports.call('renderer', 'send', JSON.stringify(request(shutdownRequest, 'shutdown', {}))); + const listed = await send('listCanvasTypes', { channel: canvasChat }); + const opened = await send('openCanvas', openParams); + const subscription = await send('subscribe', { channel: opened.canvas.resource }); + await ports.call('renderer', 'send', JSON.stringify(notification('dispatchAction', { + channel: opened.canvas.resource, clientSeq: 1, + action: { type: ActionType.CanvasTitleChanged, title: 'Forged', revision: 100 }, + }))); + assert.strictEqual(stateManager.getCanvasState(opened.canvas.resource)?.title, 'Counter'); + const source = await send('resolveCanvasSource', { channel: opened.canvas.resource }); + const invoked = await send('invokeCanvasAction', { channel: opened.canvas.resource, incarnation: opened.canvas.identity.incarnation, actionId: 'increment', requestId: 'invoke' }); + const restarted = await send('restartCanvasProvider', { channel: opened.canvas.resource, incarnation: opened.canvas.identity.incarnation, requestId: 'restart' }); + const callsBeforeMetadata = fixture.facet.calls.length; + const icon = { src: 'file:///canvas-icons/transport.png' }; + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [{ ...fixture.facet.instance(), icon }] }); + await timeout(0); + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [fixture.facet.instance()] }); + await timeout(0); + const metadataEffects = fixture.facet.calls.slice(callsBeforeMetadata); + const current = stateManager.getCanvasState(opened.canvas.resource)!; + const closed = await send('closeCanvas', { channel: current.resource, revision: current.revision, requestId: 'close' }); + assert.deepStrictEqual({ + capability: initialized.canvases, initialization: supportsAgentHostCanvasChatInitialization(initialized), + artifactRemoval: supportsAgentHostArtifactRemoval(initialized), + shutdown: findResponse(received, shutdownRequest), shutdownCalls: agentService.shutdownCalls, + types: listed.types.length, subscription: subscription.snapshot?.resource, + source: source.source, result: invoked.result, restarted, closed, + canvasDeltas: received.some(message => isJsonRpcNotification(message) && message.method === 'action' && message.params.channel === opened.canvas.resource), + iconChanges: received.flatMap(message => isJsonRpcNotification(message) && message.method === 'action' && message.params.channel === opened.canvas.resource && message.params.action.type === ActionType.CanvasIconChanged ? [message.params.action.icon] : []), + metadataEffects, + remaining: stateManager.getCanvasState(opened.canvas.resource), + }, { + capability: {}, initialization: true, artifactRemoval: false, + shutdown: { jsonrpc: '2.0', id: shutdownRequest, error: { code: JsonRpcErrorCodes.MethodNotFound, message: 'Method not found: shutdown' } }, shutdownCalls: 0, + types: 1, subscription: openParams.canvas, source: fixture.facet.resolveResult, result: { count: 1 }, restarted: null, closed: null, canvasDeltas: true, remaining: undefined, + iconChanges: [icon, null], metadataEffects: [], + }); + }); + + for (const allowExtensionMethods of [true, false]) { + test(`delayed canvas negotiation preserves independent artifact capability when management is ${allowExtensionMethods}`, async () => { + const fixture = enableCanvases(); + const readiness = new DeferredPromise(); + fixture.facet.available = false; + fixture.facet.readiness = readiness.p; + const delayedServer = disposables.add(new MockProtocolServer()); + disposables.add(new ProtocolServerHandler( + agentService, stateManager, delayedServer, { allowExtensionMethods }, + disposables.add(new AgentHostFileSystemProvider()), logService, + agentHostTelemetryService, managedSettingsService, clientConnections, fixture.service, + )); + const transport = disposables.add(new MockProtocolTransport(AgentHostTransportKind.MessagePort)); + delayedServer.simulateConnection(transport); + const pending = call(transport, 'initialize', { + channel: ROOT_STATE_URI, clientId: `delayed-${allowExtensionMethods}`, protocolVersions: [PROTOCOL_VERSION], capabilities: { canvases: {} }, + }); + await timeout(0); + fixture.facet.available = true; + await readiness.complete(); + const initialized = await pending; + assert.deepStrictEqual({ + initialization: supportsAgentHostCanvasChatInitialization(initialized), + artifactRemoval: supportsAgentHostArtifactRemoval(initialized), + providerCalls: fixture.facet.calls, + }, { initialization: true, artifactRemoval: allowExtensionMethods, providerCalls: [] }); + }); + } + + test('both peers and the actual provider must support canvases', async () => { + const fixture = enableCanvases(); + const old = await connect('old-peer', true, '0.9.0'); + const absent = await connect('not-offered', false); + fixture.facet.available = false; + const unsupported = await connect('unsupported-runtime'); + for (const connection of [old, absent, unsupported]) { + assert.strictEqual(connection.initialized.canvases, undefined); + assert.strictEqual(supportsAgentHostCanvasChatInitialization(connection.initialized), false); + await assert.rejects(call(connection.transport, 'openCanvas', openParams), error => error instanceof ProtocolError && error.code === JsonRpcErrorCodes.MethodNotFound); + for (const method of [InitializeCanvasChatExtensionMethod, CancelCanvasChatInitializationExtensionMethod] as const) { + await assert.rejects(call(connection.transport, method, { channel: canvasChat, requestId: 'initialize' }), error => error instanceof ProtocolError && error.code === JsonRpcErrorCodes.MethodNotFound); + } + } + assert.deepStrictEqual(fixture.facet.calls, []); + }); + + test('old and unoffered peers never receive session canvas catalog deltas', async () => { + const fixture = enableCanvases(); + const old = await connect('old-catalog', true, '0.9.0', [canvasSession]); + const absent = await connect('absent-catalog', false, PROTOCOL_VERSION, [canvasSession]); + const current = await connect('current-catalog', true, PROTOCOL_VERSION, [canvasSession]); + for (const { transport } of [old, absent, current]) { + transport.sent.length = 0; + } + fixture.facet.publish({ ...fixture.facet.snapshot, instances: [fixture.facet.instance()] }); + await call(current.transport, 'listCanvasTypes', { channel: canvasChat }); + const added = (transport: MockProtocolTransport) => transport.sent.some(message => isJsonRpcNotification(message) && message.method === 'action' && message.params.action.type === ActionType.SessionCanvasSet); + assert.deepStrictEqual([added(old.transport), added(absent.transport), added(current.transport)], [false, false, true]); + }); + + test('already-started startup readiness is awaited, never initiated by negotiation', async () => { + const fixture = enableCanvases(); + fixture.facet.available = false; + const ready = new DeferredPromise(); + fixture.facet.readiness = ready.p; + const connected = connect('waiting-for-runtime'); + fixture.facet.available = true; + await ready.complete(); + assert.deepStrictEqual([(await connected).initialized.canvases, fixture.facet.calls], [{}, []]); + }); + + test('request-ID retries deduplicate only on the original transport', async () => { + const fixture = enableCanvases(); + const first = await connect('retry-client'); + const opened = await call(first.transport, 'openCanvas', openParams); + assert.deepStrictEqual(await call(first.transport, 'openCanvas', openParams), opened); + await assert.rejects(call(first.transport, 'openCanvas', { ...openParams, title: 'Different bytes' })); + first.transport.simulateClose(); + const second = await connect('retry-client'); + await call(second.transport, 'openCanvas', openParams); + assert.deepStrictEqual(fixture.facet.calls, ['prepare', 'open', 'prepare', 'open']); + }); + + test('initial canvas subscriptions and malformed reserved channels never restore a provider', async () => { + const fixture = enableCanvases(); + const seed = disposables.add(fixture.service.connect('seed')); + const opened = await seed.openCanvas(openParams); + fixture.facet.calls.length = 0; + const connected = await connect('pure-reader', true, PROTOCOL_VERSION, [opened.canvas.resource]); + await call(connected.transport, 'resolveCanvasSource', { channel: opened.canvas.resource }); + await assert.rejects(call(connected.transport, 'subscribe', { channel: 'ahp-canvas://invalid/authority' })); + assert.deepStrictEqual({ + resources: connected.initialized.snapshots.map(snapshot => snapshot.resource), + restores: agentService.subscribeCalls, calls: fixture.facet.calls, + }, { resources: [opened.canvas.resource], restores: [], calls: ['resolve:pure-reader'] }); + }); + + test('reverse consent is nonce- and transport-bound, cancelled outside any turn', async () => { + const fixture = enableCanvases(); + const source = await connect('source'); + const other = await connect('other'); + const cancellation = disposables.add(new CancellationTokenSource()); + let settled = false; + const approval = fixture.service.requestApproval(canvasChat, 'Allow this exact source?', cancellation.token, 'source').then(value => { settled = true; return value; }); + const reverse = findRequest(source.transport.sent, RequestAgentHostCanvasApprovalExtensionMethod); + assert.ok(reverse); + const params = reverse.params as IAgentHostCanvasApprovalRequest; + other.transport.simulateMessage({ jsonrpc: '2.0', id: reverse.id, result: { requestId: params.requestId, approved: true } }); + await Promise.resolve(); + assert.strictEqual(settled, false); + cancellation.cancel(); + assert.strictEqual(await approval, false); + assert.ok(source.transport.sent.some(message => isJsonRpcNotification(message) && String(message.method) === CancelAgentHostCanvasApprovalExtensionMethod)); + source.transport.simulateMessage({ jsonrpc: '2.0', id: reverse.id, result: { requestId: params.requestId, approved: true } }); + const next = fixture.service.requestApproval(canvasChat, 'Try with a fresh nonce?', CancellationToken.None, 'source'); + const fresh = findRequest([...source.transport.sent].reverse(), RequestAgentHostCanvasApprovalExtensionMethod); + assert.ok(fresh); + source.transport.simulateMessage({ jsonrpc: '2.0', id: fresh.id, result: { requestId: 'wrong-nonce', approved: true } }); + assert.deepStrictEqual([await next, stateManager.getChatState(canvasChat)?.activeTurn], [false, undefined]); + }); + }); + test('handshake returns initialize response', () => { const transport = connectClient('client-1'); @@ -1222,6 +1531,7 @@ suite('ProtocolServerHandler', () => { NullTelemetryService, managedSettingsService, clientConnections, + unavailableCanvases, )); const transport = new MockProtocolTransport(); localServer.simulateConnection(transport); @@ -1993,6 +2303,77 @@ suite('ProtocolServerHandler', () => { }); }); + suite('disposeSession', () => { + test('preserves the canonical session channel and cancels all its pending chats', async () => { + const otherSession = 'copilot:/other-session'; + const peerChat = buildChatUri(sessionUri, 'registered-peer'); + const pendingChat = buildChatUri(sessionUri, 'pending-peer'); + stateManager.createSession(makeSessionSummary()); + stateManager.addChat(sessionUri, peerChat); + stateManager.createSession(makeSessionSummary(otherSession)); + const fixture = createCanvasServices(disposables, stateManager, clientConnections); + canvasService = fixture.service; + const leases = [defaultChatUri, peerChat, pendingChat, buildDefaultChatUri(otherSession)] + .map(chat => disposables.add(fixture.service.beginChatCreation(chat))); + const transport = connectClient('dispose-canonical'); + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'disposeSession', { channel: sessionUri, session: otherSession })); + assert.deepStrictEqual({ + response: await response, + disposed: agentService.disposedSessions, + cancelled: leases.map(lease => lease.token.isCancellationRequested), + sessionExists: !!stateManager.getSessionState(sessionUri), + otherExists: !!stateManager.getSessionState(otherSession), + providerCalls: fixture.facet.calls, + errors: logService.errorCount, + }, { + response: { jsonrpc: '2.0', id: 2, result: null }, + disposed: [sessionUri], cancelled: [true, true, true, false], + sessionExists: false, otherExists: true, providerCalls: [], errors: 0, + }); + }); + + for (const [name, params] of [ + ['missing params', undefined], + ['null params', null], + ['missing channel', {}], + ['legacy session params', { session: sessionUri }], + ['non-string channel', { channel: 42 }], + ['empty channel', { channel: '' }], + ] as const) { + test(`rejects ${name} before cancellation or disposal`, async () => { + const cancelled: string[] = []; + canvasService = { + ...unavailableCanvases, + cancelChatInitialization: chat => cancelled.push(chat), + cancelSessionInitialization: session => cancelled.push(session), + }; + const transport = connectClient('dispose-invalid'); + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'disposeSession', params)); + assert.deepStrictEqual({ + response: await response, disposed: agentService.disposedSessions, cancelled, errors: logService.errorCount, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: JsonRpcErrorCodes.InvalidParams, message: 'channel must be a non-empty session URI string' } }, + disposed: [], cancelled: [], errors: 1, + }); + }); + } + + test('preserves provider disposal errors and their logging', async () => { + agentService.disposeSessionError = new ProtocolError(AhpErrorCodes.PermissionDenied, 'Original disposal failure', { reason: 'provider rejected disposal' }); + const transport = connectClient('dispose-error'); + const response = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'disposeSession', { channel: sessionUri })); + assert.deepStrictEqual({ + response: await response, disposed: agentService.disposedSessions, errors: logService.errorCount, + }, { + response: { jsonrpc: '2.0', id: 2, error: { code: AhpErrorCodes.PermissionDenied, message: 'Original disposal failure', data: { reason: 'provider rejected disposal' } } }, + disposed: [sessionUri], errors: 1, + }); + }); + }); + suite('createChat / disposeChat', () => { const peerChat = buildChatUri(sessionUri, 'peer-1'); @@ -2586,6 +2967,7 @@ suite('ProtocolServerHandler', () => { telemetryService, managedSettingsService, tracker, + unavailableCanvases, ))); } @@ -2631,6 +3013,7 @@ suite('ProtocolServerHandler', () => { telemetryService, managedSettingsService, tracker, + unavailableCanvases, )); const transport = new MockProtocolTransport(); listener.simulateConnection(transport); @@ -2679,6 +3062,7 @@ suite('ProtocolServerHandler', () => { localTelemetry, managedSettingsService, clientConnections, + unavailableCanvases, )); const counts: number[] = []; localDisposables.add(localHandler.onDidChangeConnectionCount(count => counts.push(count))); @@ -2728,6 +3112,7 @@ suite('ProtocolServerHandler', () => { localTelemetry, managedSettingsService, clientConnections, + unavailableCanvases, )); const countEvents: number[] = []; localDisposables.add(localHandler.onDidChangeConnectionCount(count => countEvents.push(count))); @@ -2769,6 +3154,7 @@ suite('ProtocolServerHandler', () => { localTelemetry, managedSettingsService, clientConnections, + unavailableCanvases, )); const countEvents: number[] = []; localDisposables.add(localHandler.onDidChangeConnectionCount(count => countEvents.push(count))); @@ -2818,6 +3204,7 @@ suite('ProtocolServerHandler', () => { localTelemetry, managedSettingsService, clientConnections, + unavailableCanvases, )); const counts: number[] = []; localDisposables.add(localHandler.onDidChangeConnectionCount(count => counts.push(count))); @@ -4069,6 +4456,7 @@ suite('ProtocolServerHandler', () => { NullTelemetryService, managedSettingsService, clientConnections, + unavailableCanvases, )); const secondTransport = new MockProtocolTransport(); secondServer.simulateConnection(secondTransport); @@ -4174,6 +4562,7 @@ suite('ProtocolServerHandler', () => { NullTelemetryService, managedSettingsService, clientConnections, + unavailableCanvases, )); const counts: number[] = []; localDisposables.add(combinedHandler.onDidChangeConnectionCount(count => counts.push(count))); @@ -4313,6 +4702,7 @@ suite('ProtocolServerHandler', () => { NullTelemetryService, managedSettingsService, clientConnections, + unavailableCanvases, )); }); @@ -4485,6 +4875,7 @@ suite('ProtocolServerHandler', () => { NullTelemetryService, managedSettingsService, clientConnections, + unavailableCanvases, )); }); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts index feb76e8ab8f36..8d36fd273a580 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts @@ -18,7 +18,7 @@ import { ActionType, type RootAgentsChangedAction } from '../../../common/state/ import { AgentHostCodexEnabledConfigKey, AgentHostWorkspaceTrustConfigKey } from '../../../common/agentHostSchema.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../common/agent.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; -import { type SubscribeResult } from '../../../common/state/protocol/commands.js'; +import { type DisposeSessionParams, type SubscribeResult } from '../../../common/state/protocol/commands.js'; import { buildDefaultChatUri, customizationId, CustomizationType, MessageKind, ROOT_STATE_URI, type ClientPluginCustomization, type DirectoryCustomization, type McpServerCustomization, type PluginCustomization, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification, type IServerHandle, startRealServer, stopServer, TestProtocolClient } from '../serverIntegrationTestHelpers.js'; import { CODEX_SDK_ROOT } from '../e2e/providers/codexTestConfiguration.js'; @@ -146,7 +146,7 @@ suite('Agent Host Provider Integration — Codex Customizations', function () { teardown(async function () { for (const session of createdSessions) { try { - await client.call('disposeSession', { session }, 5000); + await client.call('disposeSession', { channel: session } satisfies DisposeSessionParams, 5000); } catch { /* best-effort */ } } createdSessions.length = 0; diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts index 60ff71ea5c8c7..2cbfbc0e5fd8b 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts @@ -16,6 +16,7 @@ import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; import { AgentHostConfigKey, type SessionCustomizationDiscoveryMode } from '../../../common/agentHostCustomizationConfig.js'; import { ActionType, SessionCustomizationsChangedAction } from '../../../common/state/sessionActions.js'; +import type { DisposeSessionParams } from '../../../common/state/protocol/commands.js'; import { customizationId, CustomizationType, ISessionWithDefaultChat, ROOT_STATE_URI, type ClientPluginCustomization, type DirectoryCustomization, type PluginCustomization, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; import { type AhpNotification } from '../../../common/state/sessionProtocol.js'; import { createProviderSession, dispatchTurn, type IAgentHostProviderTestConfig } from '../providerIntegrationTestHelpers.js'; @@ -168,7 +169,7 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () const disposeErrors: string[] = []; for (const session of createdSessions) { try { - await client.call('disposeSession', { session }, 15_000); + await client.call('disposeSession', { channel: session } satisfies DisposeSessionParams, 15_000); } catch (error) { disposeErrors.push(`Failed to dispose session ${session}: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts index 62ecbc201ada9..e26cc2305cd92 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotMockLlm.integrationTest.ts @@ -16,6 +16,7 @@ import { join } from '../../../../../base/common/path.js'; import { isWindows } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { ActionType, type ChatToolCallCompleteAction, type ChatToolCallReadyAction } from '../../../common/state/sessionActions.js'; +import type { DisposeSessionParams } from '../../../common/state/protocol/commands.js'; import { buildDefaultChatUri, ResponsePartKind, SessionStatus, type ISessionWithDefaultChat } from '../../../common/state/sessionState.js'; import { ToolCallConfirmationReason } from '../../../common/state/protocol/channels-chat/state.js'; import { AgentHostSessionReleaseRetryMsEnvVar, AgentHostSessionResidencyLimitEnvVar } from '../../../common/agentService.js'; @@ -67,7 +68,7 @@ suite('Agent Host Provider Integration — Copilot with Mock LLM', function () { teardown(async function () { for (const session of createdSessions) { try { - await client.call('disposeSession', { session }, 5000); + await client.call('disposeSession', { channel: session } satisfies DisposeSessionParams, 5000); } catch { /* best-effort */ } } createdSessions.length = 0; @@ -179,7 +180,7 @@ suite('Agent Host Provider Integration — Copilot Idle Release', function () { teardown(async function () { for (const session of createdSessions) { try { - await client.call('disposeSession', { session }, 5000); + await client.call('disposeSession', { channel: session } satisfies DisposeSessionParams, 5000); } catch { /* best-effort */ } } createdSessions.length = 0; diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index df5268a854ca0..a67e388dc66da 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -29,6 +29,33 @@ suite('SessionDatabase', () => { }); ensureNoDisposablesAreLeakedInTestSuite(); + suite('native message provenance', () => { + test('restores both boundary IDs and follows fork remapping and cascade deletion', async () => { + db = await SessionDatabase.open(':memory:'); + await db.setTurnMessageOrigin('first', 'external-runtime-participant'); + await db.setTurnEventId('first', 'sdk-first'); + await db.setTurnMessageOrigin('discarded', 'external-runtime-participant'); + const before = await db.getTurnMessageOrigins(); + await db.remapTurnIds(new Map([['first', 'forked']]), new Map([['forked', 'sdk-forked']])); + const forked = await db.getTurnMessageOrigins(); + await db.deleteAllTurns(); + assert.deepStrictEqual([before, forked, await db.getTurnMessageOrigins()], [ + new Map([['first', 'external-runtime-participant'], ['sdk-first', 'external-runtime-participant'], ['discarded', 'external-runtime-participant']]), + new Map([['forked', 'external-runtime-participant'], ['sdk-forked', 'external-runtime-participant']]), + new Map(), + ]); + }); + + test('reads observe preceding fire-and-forget writes and pruning', async () => { + db = await SessionDatabase.open(':memory:'); + const written = db.setTurnMessageOrigin('native', 'external-runtime-participant'); + const removed = db.deleteTurn('native'); + const restored = db.getTurnMessageOrigins(); + await Promise.all([written, removed]); + assert.deepStrictEqual(await restored, new Map()); + }); + }); + suite('initialization', () => { test('retries after a transient initialization failure', async () => { diff --git a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts index 8a236206ac2c8..17875220df429 100644 --- a/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/worktreeIsolation.test.ts @@ -311,6 +311,74 @@ suite('WorktreeIsolation', () => { }); }); + test('canvas initialization prepares and persists a worktree without a model prompt', async () => { + let modelCalls = 0; + branchExists = false; + const isolation = createIsolation(disposables, { + branchNameGenerator: new AgentBranchNameGenerator({ + ...createNullCopilotApiService(), + utilityChatCompletion: async () => { modelCalls++; throw new Error('No model prompt is available.'); }, + }, new NullLogService()), + }); + const request = { sessionUri, sessionId, workingDirectory: repoRoot, config: { isolation: 'worktree', branch: 'main' } }; + isolation.notePending(sessionId); + const first = await isolation.resolveForInitialization(request); + const second = await isolation.resolveForInitialization(request); + assert.deepStrictEqual({ + modelCalls, created: addWorktreeCalls.length, same: first.toString() === second.toString(), + persisted: (await isolation.readWorktreeMetadata(sessionUri))?.worktreePath?.toString(), + resolved: first.toString(), pending: isolation.isWorkingDirectoryPending(sessionId), + }, { modelCalls: 0, created: 1, same: true, persisted: first.toString(), resolved: first.toString(), pending: false }); + }); + + test('canvas initialization rejects a folder fallback even when first-send cleared its pending marker', async () => { + const isolation = createIsolation(disposables); + const request = { sessionUri, sessionId, workingDirectory: repoRoot, config: { isolation: 'worktree' } }; + isolation.notePending(sessionId); + await isolation.resolveOnFirstSend(request); + assert.strictEqual(isolation.isWorkingDirectoryPending(sessionId), false); + await assert.rejects(isolation.resolveForInitialization(request), /not prepared and persisted/); + assert.deepStrictEqual([addWorktreeCalls, isolation.getResolvedWorktree(sessionId)], [[], undefined]); + }); + + test('canvas initialization preserves a worktree creation failure and does not clear pending', async () => { + const error = new Error('checkout failed'); + const isolation = createIsolation(disposables, { + gitService: { + ...createGitService(), addWorktree: async () => { throw error; }, + } + }); + isolation.notePending(sessionId); + await assert.rejects(isolation.resolveForInitialization({ + sessionUri, sessionId, workingDirectory: repoRoot, config: { isolation: 'worktree', branch: 'main' }, + }), candidate => candidate === error); + assert.strictEqual(isolation.isWorkingDirectoryPending(sessionId), true); + }); + + test('canvas initialization rejects a created worktree whose metadata was not durably written', async () => { + const failing = new class extends TestSessionDatabase { + override async setMetadata(): Promise { throw new Error('storage unavailable'); } + }(); + const isolation = createIsolation(disposables, { sessionDataService: createSessionDataService(failing) }); + isolation.notePending(sessionId); + await assert.rejects(isolation.resolveForInitialization({ + sessionUri, sessionId, workingDirectory: repoRoot, config: { isolation: 'worktree', branch: 'main' }, + }), /not prepared and persisted/); + assert.deepStrictEqual([addWorktreeCalls.length, isolation.isWorkingDirectoryPending(sessionId)], [1, true]); + }); + + test('canvas initialization cannot reuse a worktree after a failed deletion', async () => { + const isolation = createIsolation(disposables, { + gitService: { + ...createGitService(), removeWorktree: async () => { throw new Error('worktree busy'); }, + } + }); + const request = { sessionUri, sessionId, workingDirectory: repoRoot, config: { isolation: 'worktree', branch: 'main' } }; + const worktree = await isolation.resolveForInitialization(request); + await assert.rejects(isolation.removeSessionWorktree(sessionId, { repositoryRoot: repoRoot, worktree }), /worktree busy/); + await assert.rejects(isolation.resolveForInitialization(request), /cleanup is pending/); + }); + test('resolveWorkingDirectory creates a worktree, persists metadata, queues the announcement, and is idempotent', async () => { const isolation = createIsolation(disposables); const config = { [SessionConfigKey.Isolation]: 'worktree', [SessionConfigKey.Branch]: 'main' }; diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index d9505fe64fcab..e45a7e0e85feb 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,7 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; +import { extUriBiasedIgnorePathCase, isEqual } from '../../../base/common/resources.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ITunnelProxyInfo } from '../../tunnel/common/tunnelProxy.js'; @@ -147,6 +147,25 @@ export interface IBrowserViewTheme { readonly reducedMotion?: boolean; } +/** Value-only, one-way theme defaults for an external canvas's main frame. */ +export interface IBrowserCanvasTheme { + readonly cssVariables: Readonly>; + readonly attributes: Readonly>; + readonly colorScheme: 'dark' | 'light'; + readonly stylesheets: Readonly>; +} + +/** Schemes suitable for opening a user-initiated external canvas link in an OS application. */ +export function isExternalCanvasLinkAllowed(value: string): boolean { + try { + const uri = URI.parse(value, true); + return ['http', 'https', 'mailto'].includes(uri.scheme) && !uri.authority.includes('@') + && (uri.scheme === 'mailto' || !!uri.authority); + } catch { + return false; + } +} + /** * The full set of configuration a window contributes for the browser views it * owns. Sent as a single unit by the owning window. @@ -154,6 +173,7 @@ export interface IBrowserViewTheme { export interface IBrowserViewWindowConfiguration { /** Theme variables for injected UI. */ readonly theme: IBrowserViewTheme; + readonly canvasTheme?: IBrowserCanvasTheme; /** Map of command ID to accelerator label for context menus. */ readonly keybindings: { [commandId: string]: string }; @@ -228,6 +248,13 @@ export interface IBrowserViewCaptureScreenshotOptions { awaitNextPaint?: boolean; } +/** Bounded semantic text from Chromium, without page URLs, console logs, or automation handles. */ +export interface IBrowserViewAccessibilitySnapshot { + readonly text: string; + readonly truncated: boolean; + readonly scope: 'main-frame'; +} + /** Identifies who controls a browser view. */ export type IBrowserViewOwner = | { readonly type: 'user' } @@ -262,12 +289,59 @@ export interface IBrowserViewHost { readonly sessionId?: string; } +/** A native page whose editor and logical identity belong to another workbench component. */ +export interface IBrowserViewExternalPresentation { + readonly type: 'external'; + readonly resource: UriComponents; +} + +/** Snap absolute CSS bounds to the native view's host-zoom pixel grid. */ +export function snapBrowserViewBounds(bounds: IBrowserViewRect, zoom: number): IBrowserViewRect { + const snap = (value: number) => Math.floor(value * zoom) / zoom; + return { x: snap(bounds.x), y: snap(bounds.y), width: snap(bounds.width), height: snap(bounds.height) }; +} + +export function externalBrowserViewStorageAffinity(resource: UriComponents): string { + return `external:${URI.revive(resource).toString()}`; +} + +/** Reject changes to the presentation or owning window of an existing native page. */ +export function validateBrowserViewReuse(existing: IBrowserViewInfo, options: IBrowserViewCreateOptions): void { + if (!existing.presentation && !options.presentation) { + return; + } + if (existing.host.windowId !== options.host.windowId + || !existing.presentation || !options.presentation + || !isEqual(URI.revive(existing.presentation.resource), URI.revive(options.presentation.resource))) { + throw new Error('Native browser presentation or owning window does not match.'); + } +} + +/** External presentations never inherit ordinary browser storage or an automation audience. */ +export function validateExternalBrowserViewOptions(options: IBrowserViewCreateOptions, existingViews: Iterable<{ readonly presentation?: IBrowserViewExternalPresentation }> = []): void { + if (!options.presentation) { + return; + } + if (options.owner.type !== 'user' || options.initialAudiences?.length !== 0 + || typeof options.session === 'string' || options.session.scope !== BrowserViewStorageScope.Agent + || options.session.affinity !== externalBrowserViewStorageAffinity(options.presentation.resource) + || options.associatedResource) { + throw new Error('External browser presentations require isolated storage and no agent access.'); + } + for (const view of existingViews) { + if (view.presentation && isEqual(URI.revive(view.presentation.resource), URI.revive(options.presentation.resource))) { + throw new Error('A native view for this logical canvas is already attached. Hide that view before opening another.'); + } + } +} + /** * Summary information about a browser view, including its current state and * ownership. Returned by the main service when listing or creating views. */ export interface IBrowserViewInfo { readonly id: string; + readonly presentation?: IBrowserViewExternalPresentation; readonly host: IBrowserViewHost; readonly owner: IBrowserViewOwner; readonly associatedResource?: UriComponents; @@ -303,6 +377,7 @@ export interface IBrowserViewCreationContext { /** Complete main-process creation contract for a browser view. */ export interface IBrowserViewCreateOptions extends IBrowserViewCreationContext { + readonly presentation?: IBrowserViewExternalPresentation; readonly associatedResource?: UriComponents; readonly initialUrl?: string; readonly openSource?: IntegratedBrowserOpenSource; @@ -365,6 +440,8 @@ export interface IBrowserViewLoadError { errorCode: number; errorDescription: string; certificateError?: IBrowserViewCertificateError; + /** Workspace Trust denied access to this local file. */ + fileAccessDenied?: boolean; } export interface IBrowserViewCertificateError { @@ -505,6 +582,8 @@ export interface IBrowserDeviceProfile { export const browserViewIsolatedWorldId = 999; export interface IBrowserViewService { + /** Read-only user accessibility; does not grant agent access or expose a CDP connection. */ + getAccessibilitySnapshot(id: string, expectedHostWindowId: number): Promise; /** * Fires when a new browser view is created. */ diff --git a/src/vs/platform/browserView/common/browserViewGroup.ts b/src/vs/platform/browserView/common/browserViewGroup.ts index 5bdfbc1537bbe..bbcfd416f8194 100644 --- a/src/vs/platform/browserView/common/browserViewGroup.ts +++ b/src/vs/platform/browserView/common/browserViewGroup.ts @@ -5,7 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; -import { IBrowserViewAudience, IBrowserViewCreationContext, matchesBrowserViewAudience } from './browserView.js'; +import { IBrowserViewAudience, IBrowserViewCreationContext, IBrowserViewExternalPresentation, matchesBrowserViewAudience } from './browserView.js'; import { CDPEvent, CDPRequest, CDPResponse } from './cdp/types.js'; export const ipcBrowserViewGroupChannelName = 'browserViewGroup'; @@ -31,7 +31,10 @@ export interface IBrowserViewGroupFilter { readonly browserIds?: readonly string[]; } -export function matchesBrowserViewGroupFilter(browserId: string, audiences: readonly IBrowserViewAudience[], filter: IBrowserViewGroupFilter): boolean { +export function matchesBrowserViewGroupFilter(browserId: string, audiences: readonly IBrowserViewAudience[], filter: IBrowserViewGroupFilter, presentation?: IBrowserViewExternalPresentation): boolean { + if (presentation) { + return false; + } const audienceFilter = filter.audience; return filter.browserIds?.includes(browserId) === true || (audienceFilter !== undefined && audiences.some(audience => matchesBrowserViewAudience(audienceFilter, audience))); diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts index 1b6c35278abaa..05a4d1bf1cad3 100644 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -7,7 +7,7 @@ /* eslint-disable no-restricted-syntax */ // Only `import type` is allowed in preload scripts — Electron preloads cannot resolve module imports at runtime. -import type { BrowserElementSelectionMode, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewPreloadLocalizedStrings, IBrowserViewTheme, IBrowserViewRect } from '../common/browserView.js'; +import type { BrowserElementSelectionMode, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewPreloadLocalizedStrings, IBrowserViewTheme, IBrowserViewRect, IBrowserCanvasTheme } from '../common/browserView.js'; const commentElementSelectionMode = 'comment' as BrowserElementSelectionMode; let localizedStrings: IBrowserViewPreloadLocalizedStrings = { @@ -86,9 +86,9 @@ function init() { const isMac = navigator.platform.indexOf('Mac') >= 0; - // Alt+Key special character handling (Alt + Numpad keys on Windows/Linux, Alt + any key on Mac) + // Preserve character entry through Option on macOS and Alt+Numpad on Windows/Linux. if (event.altKey && !event.ctrlKey && !event.metaKey) { - if (isMac || /^Numpad\d+$/.test(event.code)) { + if ((isMac && !isNonEditingKey) || /^Numpad\d+$/.test(event.code)) { return; } } @@ -188,6 +188,70 @@ function init() { elementPicker.setTheme(theme); areaPicker.setTheme(theme); }); + let canvasTheme: IBrowserCanvasTheme | undefined; + const canvasStyles = new Map(); + const canvasAttributes = new WeakMap>(); + const applyCanvasTheme = () => { + if (window !== window.top || !document.documentElement) { + return; + } + const root = document.documentElement; + const style = (name: string, css: string) => { + let element = canvasStyles.get(name); + if (!element) { + element = document.createElement('style'); + element.setAttribute(name === 'variables' ? 'data-copilot-canvas-theme-defaults' : 'data-copilot-canvas-theme', name); + canvasStyles.set(name, element); + } + if (!element.isConnected) { + const parent = document.head ?? root; + parent.insertBefore(element, parent.firstChild); + } + element.textContent = css; + }; + const declarations = Object.entries(canvasTheme?.cssVariables ?? {}) + .filter(([name, value]) => /^--[a-zA-Z][a-zA-Z0-9-]*$/.test(name) && value.length <= 512 && !/[{};]/.test(value)) + .map(([name, value]) => `${name}: ${value};`); + if (canvasTheme) { + declarations.push(`color-scheme: ${canvasTheme.colorScheme};`, '-webkit-font-smoothing: antialiased;', '-moz-osx-font-smoothing: grayscale;'); + } + style('variables', `:root { ${declarations.join(' ')} }`); + style('rampa', canvasTheme?.stylesheets.rampa ?? ''); + for (const element of [root, document.body]) { + if (!element) { + continue; + } + const previous = canvasAttributes.get(element) ?? new Map(); + for (const [name, value] of previous) { + if (!Object.hasOwn(canvasTheme?.attributes ?? {}, name) && element.getAttribute(name) === value) { + element.removeAttribute(name); + } + } + const next = new Map(); + for (const [name, value] of Object.entries(canvasTheme?.attributes ?? {})) { + if (!['data-color-mode', 'data-dark-theme', 'data-light-theme', 'data-theme-source', 'data-theme-tone', 'data-visual-mode'].includes(name)) { + continue; + } + if (!element.hasAttribute(name) || element.getAttribute(name) === previous.get(name)) { + element.setAttribute(name, value); + next.set(name, value); + } + } + canvasAttributes.set(element, next); + if (canvasTheme) { + element.classList.add('pointer-on-hover'); + } + } + }; + ipcRenderer.on('vscode:browserView:canvasTheme', (_event: unknown, theme: IBrowserCanvasTheme | undefined) => { + canvasTheme = theme; + applyCanvasTheme(); + }); + document.addEventListener('DOMContentLoaded', () => { + if (canvasTheme) { + applyCanvasTheme(); + } + }, { once: true }); ipcRenderer.on('vscode:browserView:setLocalizedStrings', (_event: unknown, strings: IBrowserViewPreloadLocalizedStrings) => { localizedStrings = strings; elementPicker.updateLocalizedStrings(); diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index 8be17ee4308ed..602495da9dfe1 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -5,10 +5,7 @@ import { session } from 'electron'; import { createHash } from 'crypto'; -import { normalize } from '../../../base/common/path.js'; -import { isLinux } from '../../../base/common/platform.js'; -import { joinPath } from '../../../base/common/resources.js'; -import { TernarySearchTree } from '../../../base/common/ternarySearchTree.js'; +import { isEqual, joinPath } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { BrowserViewStorageScope, IBrowserViewSessionOptions } from '../common/browserView.js'; @@ -16,9 +13,9 @@ import { BrowserSessionTrust, IBrowserSessionTrust } from './browserSessionTrust import { BrowserSessionHistory, IBrowserSessionHistory } from './browserSessionHistory.js'; import { BrowserSessionPermissions, IBrowserSessionPermissions } from './browserSessionPermissions.js'; import { BrowserSessionRemote, IBrowserSessionRemote } from './browserSessionRemote.js'; +import { BrowserSessionFileAccess } from './browserSessionFileAccess.js'; import { FileAccess, Schemas } from '../../../base/common/network.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { localize } from '../../../nls.js'; import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; /** @@ -38,6 +35,21 @@ import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFi * the internal registry stays consistent. */ export class BrowserSession { + private _externalResource: URI | undefined; + private _presentationValidated = false; + + validatePresentation(resource: URI | undefined): void { + if (this._presentationValidated && !isEqual(this._externalResource, resource)) { + throw new Error('Externally presented pages cannot share storage with another presentation.'); + } + if (resource) { + if (this.storageScope !== BrowserViewStorageScope.Agent) { + throw new Error('External presentations require isolated, network-filtered storage.'); + } + } + this._externalResource = resource; + this._presentationValidated = true; + } // #region Static registry @@ -218,20 +230,13 @@ export class BrowserSession { } } - private static readonly _trustedFileRoots = TernarySearchTree.forPaths(!isLinux); - private static _trustAllFiles = false; + static readonly fileAccess = new BrowserSessionFileAccess(); /** * Set trusted file roots for all browser sessions. */ static setTrustedFileRoots(roots: readonly string[], trustAllFiles: boolean): void { - BrowserSession._trustAllFiles = trustAllFiles; - BrowserSession._trustedFileRoots.clear(); - for (const root of roots) { - if (root) { - BrowserSession._trustedFileRoots.set(normalize(root), true); - } - } + BrowserSession.fileAccess.setTrustedFileRoots(roots, trustAllFiles); } // #endregion @@ -338,13 +343,10 @@ export class BrowserSession { type: 'frame', filePath: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath }); - this.electronSession.protocol.handle(Schemas.file, request => { - const filePath = normalize(URI.parse(request.url).fsPath); - if (!BrowserSession._trustAllFiles && !BrowserSession._trustedFileRoots.findSubstr(filePath)) { - return new Response(localize('browserSession.untrustedFile', 'Forbidden. File does not reside within a trusted folder.'), { status: 403 }); - } - return this.electronSession.fetch(request, { bypassCustomProtocolHandlers: true }); - }); + this.electronSession.protocol.handle(Schemas.file, request => BrowserSession.fileAccess.handleRequest( + request, + request => this.electronSession.fetch(request, { bypassCustomProtocolHandlers: true }), + )); } /** diff --git a/src/vs/platform/browserView/electron-main/browserSessionFileAccess.ts b/src/vs/platform/browserView/electron-main/browserSessionFileAccess.ts new file mode 100644 index 0000000000000..3172030980c5e --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserSessionFileAccess.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Schemas } from '../../../base/common/network.js'; +import { normalize } from '../../../base/common/path.js'; +import { isLinux } from '../../../base/common/platform.js'; +import { TernarySearchTree } from '../../../base/common/ternarySearchTree.js'; +import { URI } from '../../../base/common/uri.js'; +import type { IBrowserViewLoadError } from '../common/browserView.js'; + +/** The process-wide file allowlist supplied by Workspace Trust. */ +export class BrowserSessionFileAccess { + private readonly roots = TernarySearchTree.forPaths(!isLinux); + private trustAllFiles = false; + + setTrustedFileRoots(roots: readonly string[], trustAllFiles: boolean): void { + this.trustAllFiles = trustAllFiles; + this.roots.clear(); + for (const root of roots) { + if (root) { + this.roots.set(normalize(root), true); + } + } + } + + isAllowed(url: string): boolean { + if (!url) { + return true; + } + const resource = URI.parse(url); + return resource.scheme !== Schemas.file || this.trustAllFiles || !!this.roots.findSubstr(normalize(resource.fsPath)); + } + + getError(url: string, errorCode = -10, errorDescription = 'ERR_ACCESS_DENIED'): IBrowserViewLoadError | undefined { + return this.isAllowed(url) ? undefined : { url, errorCode, errorDescription, fileAccessDenied: true }; + } + + async handleRequest(request: Request, fetch: (request: Request) => Promise): Promise { + if (!this.isAllowed(request.url)) { + return Response.error(); + } + const response = await fetch(request); + if (!this.isAllowed(request.url)) { + await response.body?.cancel(); + return Response.error(); + } + return response; + } +} diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 6831bae319493..73d7117b35c54 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -7,11 +7,11 @@ 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'; -import { IBrowserViewAudience, IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewEditorOpenOptions, IBrowserViewPermissionRequestEvent, equalsBrowserViewAudience, isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience, IBrowserViewHost } from '../common/browserView.js'; +import { IBrowserViewAudience, IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, browserViewIsolatedWorldId, browserZoomFactors, browserZoomDefaultIndex, IBrowserViewOwner, IBrowserViewEditorOpenOptions, IBrowserViewPermissionRequestEvent, equalsBrowserViewAudience, isBrowserViewAssociatedResourceNavigation, matchesBrowserViewAudience, IBrowserViewHost, IBrowserViewExternalPresentation, isExternalCanvasLinkAllowed } from '../common/browserView.js'; import { BrowserViewEmulator } from './browserViewEmulator.js'; import { BrowserViewInspector } from './browserViewInspector.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; -import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js'; +import { ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; @@ -23,6 +23,7 @@ import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.j import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { logBrowserOpen } from '../common/browserViewTelemetry.js'; import { URI } from '../../../base/common/uri.js'; +import { registerBrowserViewWindowLifecycle } from './browserViewWindowLifecycle.js'; enum NewPageLocation { Foreground = 'foreground', @@ -129,7 +130,9 @@ export class BrowserView extends Disposable { public readonly session: BrowserSession, private readonly _createChildView: (owner: IBrowserViewOwner, url: string, electronOptions: Electron.WebContentsViewConstructorOptions | undefined, editorOptions: IBrowserViewEditorOpenOptions) => BrowserView, openContextMenu: (view: BrowserView, params: Electron.ContextMenuParams) => void, + private readonly _openExternalCanvasLink: (url: string) => void, options: Electron.WebContentsViewConstructorOptions | undefined, + public readonly presentation: IBrowserViewExternalPresentation | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService, @ILogService private readonly logService: ILogService, @@ -171,19 +174,18 @@ export class BrowserView extends Disposable { if (!this._ownerWindow) { throw new Error(`Window with ID ${host.windowId} not found`); } - this._register(this._ownerWindow.onDidClose(() => this.dispose())); - this._register(this._ownerWindow.onWillLoad((e) => { - if (e.reason === LoadReason.LOAD) { - this.dispose(); // Dispose when switching workspaces. - } else if (e.reason === LoadReason.RELOAD) { - this.setVisible(false); // Hide when reloading. - } - })); + this._register(registerBrowserViewWindowLifecycle(this._ownerWindow, this)); this._view.setVisible(false); this._ownerWindow.win?.contentView.addChildView(this._view); this._view.webContents.setWindowOpenHandler((details) => { + if (this.presentation) { + if (isExternalCanvasLinkAllowed(details.url) && this.consumePopupPermission(NewPageLocation.NewWindow)) { + this._openExternalCanvasLink(details.url); + } + return { action: 'deny' }; + } const location = (() => { switch (details.disposition) { case 'background-tab': return NewPageLocation.Background; @@ -237,7 +239,7 @@ export class BrowserView extends Disposable { this.debugger = new BrowserViewDebugger(this); this.emulator = this._register(new BrowserViewEmulator(this, this.logService)); - this.inspector = this._register(new BrowserViewInspector(this)); + this.inspector = this._register(new BrowserViewInspector(this, this.logService)); const fireRemoteStatus = () => this._onDidChangeRemoteStatus.fire(this.session.remote.isRemote); this._register(this.session.remote.onDidStart(fireRemoteStatus)); @@ -366,7 +368,9 @@ export class BrowserView extends Disposable { // Loading state events webContents.on('did-start-loading', () => { - this._lastError = undefined; + if (!this._lastError?.fileAccessDenied) { + this._lastError = undefined; + } // Don't fire loading events for e.g. same-document navigations if (webContents.isLoadingMainFrame()) { @@ -382,7 +386,7 @@ export class BrowserView extends Disposable { return; } - this._lastError = { + this._lastError = BrowserSession.fileAccess.getError(validatedURL, errorCode, errorDescription) ?? { url: validatedURL, errorCode, errorDescription, @@ -427,7 +431,13 @@ export class BrowserView extends Disposable { }); // Navigation events (when URL actually changes) - webContents.on('did-navigate', (_, url) => fireNavigationEvent(url)); + webContents.on('did-navigate', (_, url) => { + if (this._lastError?.fileAccessDenied) { + this._lastError = BrowserSession.fileAccess.getError(url); + fireLoadingEvent(webContents.isLoadingMainFrame()); + } + fireNavigationEvent(url); + }); webContents.on('did-navigate-in-page', (_, url, isMainFrame) => { // Ignore subframe (iframe) navigations: they must not rewrite the // main frame's URL bar or its history entry. @@ -742,6 +752,20 @@ export class BrowserView extends Disposable { return this._consoleLogs.join('\n'); } + revalidateFileAccess(): void { + if (this._isDisposed || this._view.webContents.isDestroyed()) { + return; + } + const error = BrowserSession.fileAccess.getError(this.getURL()); + if (!error || this._lastError?.fileAccessDenied) { + return; + } + this._lastError = error; + this.setVisible(false); + this._onDidChangeLoadingState.fire({ loading: false, error }); + this._view.webContents.reloadIgnoringCache(); + } + /** * Load a URL in this view */ diff --git a/src/vs/platform/browserView/electron-main/browserViewAccessibility.ts b/src/vs/platform/browserView/electron-main/browserViewAccessibility.ts new file mode 100644 index 0000000000000..bd1a9325b140c --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewAccessibility.ts @@ -0,0 +1,37 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { AXNode } from '../../webContentExtractor/electron-main/cdpAccessibilityDomain.js'; +import type { IBrowserViewAccessibilitySnapshot } from '../common/browserView.js'; + +/** Preserve accessible names and control states rather than a page-reading tool's links and logs. */ +export function formatBrowserViewAccessibility(nodes: readonly AXNode[]): IBrowserViewAccessibilitySnapshot { + const maxNodes = 2000; + const maxLength = 32768; + const lines: string[] = []; + let length = 0; + const includedIds = new Set(nodes.map(node => node.nodeId)); + let truncated = nodes.length > maxNodes || nodes.some(node => node.childIds?.some(id => !includedIds.has(id))); + for (const node of nodes.slice(0, maxNodes)) { + const role = node.role?.value; + const name = node.name?.value; + if (node.ignored || typeof role !== 'string' || role === 'InlineTextBox' + || role === 'RootWebArea' || typeof name !== 'string' || !name.trim()) { + continue; + } + const states = node.properties?.filter(property => + ['checked', 'pressed', 'expanded', 'selected', 'disabled', 'level'].includes(property.name) + && ['boolean', 'string', 'number'].includes(typeof property.value.value)) + .map(property => `${property.name}=${property.value.value}`) ?? []; + const line = `${role}: ${name.replace(/\s+/g, ' ')}${states.length ? ` (${states.join(', ')})` : ''}`; + if (length + line.length + 1 > maxLength) { + truncated = true; + break; + } + lines.push(line); + length += line.length + 1; + } + return { text: lines.join('\n'), truncated, scope: 'main-frame' }; +} diff --git a/src/vs/platform/browserView/electron-main/browserViewContextMenu.ts b/src/vs/platform/browserView/electron-main/browserViewContextMenu.ts new file mode 100644 index 0000000000000..17264888dd47f --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewContextMenu.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { MenuItemConstructorOptions } from 'electron'; +import { localize } from '../../../nls.js'; +import type { ILogService } from '../../log/common/log.js'; +import { type IBrowserViewExternalPresentation, isExternalCanvasLinkAllowed } from '../common/browserView.js'; + +export function createBrowserViewExternalLinkMenuItem( + presentation: IBrowserViewExternalPresentation | undefined, + url: string, + openExternal: (url: string) => Promise, + logService: ILogService, +): MenuItemConstructorOptions { + const isAllowed = () => !presentation || isExternalCanvasLinkAllowed(url); + return { + label: localize('browser.contextMenu.openLinkInExternalBrowser', "Open Link in External Browser"), + enabled: isAllowed(), + click: () => { + if (!isAllowed()) { + logService.warn('Blocked an unsupported external canvas link.'); + return; + } + void openExternal(url).catch(error => logService.error('Failed to open an external browser link.', error)); + }, + }; +} diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index 33379250b0e75..35a6cd6f0958e 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -7,6 +7,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { CDPEvent, CDPTargetInfo, ICDPConnection } from '../common/cdp/types.js'; import { BrowserView } from './browserView.js'; +import type { AXNode } from '../../webContentExtractor/electron-main/cdpAccessibilityDomain.js'; /** * Intercepts a CDP command before it is forwarded to the Electron debugger. @@ -107,6 +108,12 @@ export class BrowserViewDebugger extends Disposable { return result.targetInfo; } + async getAccessibilityTree(): Promise { + this.ensureAttached(); + const result: { nodes: AXNode[] } = await this._electronDebugger.sendCommand('Accessibility.getFullAXTree', { depth: 20 }); + return result.nodes; + } + /** * Send a CDP command. Handles Electron-specific workarounds in a single place. */ diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index 75fb879442b9b..0d1f5bc96493a 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -120,7 +120,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } private async _reconcileView(view: BrowserView): Promise { - const matches = matchesBrowserViewGroupFilter(view.id, view.audiences, this.filter); + const matches = matchesBrowserViewGroupFilter(view.id, view.audiences, this.filter, view.presentation); if (matches) { await this.addView(view.id); } else { @@ -159,6 +159,9 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I if (!view) { throw new Error(`Browser view ${viewId} not found`); } + if (view.presentation) { + throw new Error('Externally presented pages are not available to browser automation.'); + } if (this.filter.audience?.type === 'agent') { this.browserViewMainService.validateAgentAccess(view); } diff --git a/src/vs/platform/browserView/electron-main/browserViewInspector.ts b/src/vs/platform/browserView/electron-main/browserViewInspector.ts index 0099fd3d7b072..c861504bfb295 100644 --- a/src/vs/platform/browserView/electron-main/browserViewInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewInspector.ts @@ -5,11 +5,12 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { BrowserElementSelectionMode, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserElementSelectionState, IElementData, IBrowserViewTheme, IBrowserViewRect, IBrowserViewPreloadLocalizedStrings } from '../common/browserView.js'; +import { BrowserElementSelectionMode, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserElementSelectionState, IElementData, IBrowserViewTheme, IBrowserViewRect, IBrowserViewPreloadLocalizedStrings, IBrowserCanvasTheme } from '../common/browserView.js'; import { ICDPConnection } from '../common/cdp/types.js'; import type { BrowserView } from './browserView.js'; import { BrowserViewFrameInspector } from './browserViewFrameInspector.js'; import { localize } from '../../../nls.js'; +import { ILogService } from '../../log/common/log.js'; const localizedStrings: IBrowserViewPreloadLocalizedStrings = { addComment: localize('browserView.addComment', "Add Comment"), @@ -81,6 +82,7 @@ export class BrowserViewInspector extends Disposable { private readonly _activeSelection = this._register(new MutableDisposable()); private _inspectionOperation: Promise = Promise.resolve(); private _theme: IBrowserViewTheme = {}; + private _canvasTheme: IBrowserCanvasTheme | undefined; // Area selection — drag-to-select a rectangle on the top frame. // `onDidPickArea` fires exactly once per session, terminating it. @@ -102,7 +104,7 @@ export class BrowserViewInspector extends Disposable { private readonly _registry = this._register(new FrameInspectorRegistry()); - constructor(private readonly browser: BrowserView) { + constructor(private readonly browser: BrowserView, private readonly logService: ILogService) { super(); const webContents = this.browser.webContents; @@ -133,6 +135,7 @@ export class BrowserViewInspector extends Disposable { // Apply theme immediately regardless of inspector state senderFrame.postMessage('vscode:browserView:setTheme', this._theme); senderFrame.postMessage('vscode:browserView:setLocalizedStrings', localizedStrings); + this._sendCanvasTheme(senderFrame); this._registry.notifyFrameReady(senderFrame, frameToken); @@ -289,6 +292,28 @@ export class BrowserViewInspector extends Disposable { } } + setCanvasTheme(theme: IBrowserCanvasTheme | undefined): void { + if (this.browser.presentation?.type !== 'external') { + return; + } + this._canvasTheme = theme; + this._sendCanvasTheme(); + } + + private _sendCanvasTheme(senderFrame?: Electron.WebFrameMain): void { + if (!this._canvasTheme || this._store.isDisposed) { + return; + } + try { + const webContents = this.browser.webContents; + if (!webContents.isDestroyed() && (!senderFrame || senderFrame === webContents.mainFrame)) { + webContents.mainFrame.postMessage('vscode:browserView:canvasTheme', this._canvasTheme); + } + } catch { + this.logService.debug('BrowserViewInspector: Canvas frame unavailable for theme update; retained for the next main-frame preload.'); + } + } + /** * Toggle element selection mode across all frames. */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index b929bfa8cb936..5df19a6163804 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -6,7 +6,8 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { BrowserViewSessionSelector, BrowserViewStorageScope, isBrowserViewStorageScopeShareableWithAgent, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewEditorOpenOptions, IBrowserViewCreateOptions, IBrowserViewCreationContext, IBrowserViewWindowConfiguration, IBrowserDeviceProfile } from '../common/browserView.js'; +import { BrowserViewSessionSelector, BrowserViewStorageScope, isBrowserViewStorageScopeShareableWithAgent, IBrowserElementCommentsUpdate, IBrowserElementSelectionOptions, IBrowserViewAudience, IBrowserViewBounds, IBrowserViewState, IBrowserViewService, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, BrowserViewCommandId, IBrowserViewOwner, IBrowserViewInfo, IBrowserViewCreatedEvent, IBrowserViewEditorOpenOptions, IBrowserViewCreateOptions, IBrowserViewCreationContext, IBrowserViewWindowConfiguration, IBrowserDeviceProfile, IBrowserViewExternalPresentation, validateBrowserViewReuse, validateExternalBrowserViewOptions, IBrowserViewAccessibilitySnapshot } from '../common/browserView.js'; +import { createBrowserViewExternalLinkMenuItem } from './browserViewContextMenu.js'; import { clipboard, Menu, MenuItem } from 'electron'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; @@ -26,6 +27,7 @@ import { equals } from '../../../base/common/objects.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js'; +import { formatBrowserViewAccessibility } from './browserViewAccessibility.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); @@ -85,8 +87,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions): Promise { + validateExternalBrowserViewOptions(options); if (this.browserViews.has(id)) { const view = this.browserViews.get(id)!; + validateBrowserViewReuse(this._getViewInfo(view), options); return this._getViewInfo(view); } @@ -140,6 +144,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private _getViewInfo(view: BrowserView): IBrowserViewInfo { return { id: view.id, + presentation: view.presentation, host: view.host, owner: view.owner, associatedResource: view.associatedResource, @@ -255,6 +260,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } validateAgentAccess(view: BrowserView): void { + if (view.presentation) { + throw new Error('Externally presented pages are not available to browser automation.'); + } this.validateAgentStorageScope(view.session.storageScope); } @@ -269,9 +277,20 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } async setOwner(id: string, owner: IBrowserViewOwner): Promise { + if (owner.type === 'agent') { + this.validateAgentAccess(this._getBrowserView(id)); + } this._getBrowserView(id).setOwner(owner); } + async getAccessibilitySnapshot(id: string, expectedHostWindowId: number): Promise { + const view = this._getBrowserView(id); + if (view.host.windowId !== expectedHostWindowId) { + throw new Error('The accessibility snapshot belongs to another workbench window.'); + } + return formatBrowserViewAccessibility(await view.debugger.getAccessibilityTree()); + } + async layout(id: string, bounds: IBrowserViewBounds): Promise { return this._getBrowserView(id).layout(bounds); } @@ -399,6 +418,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa async updateWindowConfiguration(windowId: number, config: IBrowserViewWindowConfiguration): Promise { const oldConfig = this._windowConfigurations.get(windowId); const didThemeChange = !equals(oldConfig?.theme, config.theme); + const didCanvasThemeChange = !equals(oldConfig?.canvasTheme, config.canvasTheme); const didProxyChange = !equals(oldConfig?.proxyInfo, config.proxyInfo); this._windowConfigurations.set(windowId, config); @@ -409,6 +429,9 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa if (didThemeChange) { view.inspector.setTheme(config.theme); } + if (didCanvasThemeChange) { + view.inspector.setCanvasTheme(config.canvasTheme); + } if (didProxyChange) { view.session.remote.acquire(view.id, config.proxyInfo); } @@ -448,12 +471,15 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa trustAllFiles ||= configuration.trustAllFiles; } BrowserSession.setTrustedFileRoots([...roots], trustAllFiles); + for (const view of this.browserViews.values()) { + view.revalidateFileAccess(); + } } /** * Create a browser view backed by the given {@link BrowserSession}. */ - private _createNativeBrowserView(id: string, host: IBrowserViewCreationContext['host'], owner: IBrowserViewOwner, browserSession: BrowserSession, associatedResource?: URI, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + private _createNativeBrowserView(id: string, host: IBrowserViewCreationContext['host'], owner: IBrowserViewOwner, browserSession: BrowserSession, associatedResource?: URI, options?: Electron.WebContentsViewConstructorOptions, presentation?: IBrowserViewExternalPresentation): BrowserView { if (this.browserViews.has(id)) { throw new Error(`Browser view with id ${id} already exists`); } @@ -484,12 +510,17 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa }, editorOptions, electronOptions); }, (v, params) => this.showContextMenu(v, params), - options + url => { + void this.nativeHostMainService.openExternal(undefined, url).catch(() => this.logService.warn('Could not open a user-initiated canvas link.')); + }, + options, + presentation ); this.browserViews.set(id, view); if (windowConfiguration?.theme) { view.inspector.setTheme(windowConfiguration.theme); } + view.inspector.setCanvasTheme(windowConfiguration?.canvasTheme); Event.once(view.onDidClose)(() => { browserSession.remote.release(id); @@ -500,18 +531,24 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } private _createBrowserView(id: string, options: IBrowserViewCreateOptions, editorOpenRequest?: IBrowserViewEditorOpenOptions, electronOptions?: Electron.WebContentsViewConstructorOptions): BrowserView { + validateExternalBrowserViewOptions(options, this.browserViews.values()); const hasAgentAccess = options.owner.type === 'agent' || options.initialAudiences?.some(audience => audience.type === 'agent') === true; const browserSession = this._resolveBrowserSession(id, options.host.windowId, options.session); + browserSession.validatePresentation(URI.revive(options.presentation?.resource)); if (hasAgentAccess) { this.validateAgentStorageScope(browserSession.storageScope); } - const view = this._createNativeBrowserView(id, options.host, options.owner, browserSession, URI.revive(options.associatedResource), electronOptions); + const view = this._createNativeBrowserView(id, options.host, options.owner, browserSession, URI.revive(options.associatedResource), electronOptions, options.presentation); if (options.initialAudiences) { view.setAudiences(options.initialAudiences); } if (options.initialUrl) { void view.loadURL(options.initialUrl).catch(error => { - this.logService.error(`[BrowserViewMainService] Failed to load initial URL for browser view ${id}`, error); + if (options.presentation) { + this.logService.warn('[BrowserViewMainService] Failed to load an external presentation source.'); + } else { + this.logService.error(`[BrowserViewMainService] Failed to load initial URL for browser view ${id}`, error); + } }); } if (options.openSource) { @@ -567,7 +604,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } const windowConfiguration = this._windowConfigurations.get(view.host.windowId); - const inspectTarget = windowConfiguration?.aiFeaturesDisabled + const inspectTarget = windowConfiguration?.aiFeaturesDisabled || view.presentation ? undefined : params.frame && await view.inspector.getElementHandle(BrowserViewInspectElementId.ContextMenuTarget, params.frame); const menu = new Menu(); @@ -575,7 +612,11 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa if (params.linkURL) { menu.append(new MenuItem({ label: localize('browser.contextMenu.openLinkInNewTab', 'Open Link in New Tab'), + enabled: !view.presentation, click: () => { + if (view.presentation) { + return; + } void this.openNew(params.linkURL, { host: view.host, owner: view.owner, @@ -583,10 +624,11 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa }, { preserveFocus: true, background: true }, 'browserLinkBackground'); } })); - menu.append(new MenuItem({ - label: localize('browser.contextMenu.openLinkInExternalBrowser', 'Open Link in External Browser'), - click: () => { void this.nativeHostMainService.openExternal(undefined, params.linkURL); } - })); + menu.append(new MenuItem(createBrowserViewExternalLinkMenuItem( + view.presentation, params.linkURL, + url => this.nativeHostMainService.openExternal(undefined, url), + this.logService, + ))); menu.append(new MenuItem({ type: 'separator' })); menu.append(new MenuItem({ label: localize('browser.contextMenu.copyLink', 'Copy Link'), @@ -605,7 +647,11 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa } menu.append(new MenuItem({ label: localize('browser.contextMenu.openImageInNewTab', 'Open Image in New Tab'), + enabled: !view.presentation, click: () => { + if (view.presentation) { + return; + } void this.openNew(params.srcURL!, { host: view.host, owner: view.owner, diff --git a/src/vs/platform/browserView/electron-main/browserViewWindowLifecycle.ts b/src/vs/platform/browserView/electron-main/browserViewWindowLifecycle.ts new file mode 100644 index 0000000000000..89997d34a9792 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewWindowLifecycle.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { ICodeWindow, LoadReason } from '../../window/electron-main/window.js'; +import type { BrowserView } from './browserView.js'; + +/** Bind native content to its owning renderer, independently of its display bounds. */ +export function registerBrowserViewWindowLifecycle(ownerWindow: ICodeWindow, view: BrowserView): IDisposable { + const store = new DisposableStore(); + store.add(ownerWindow.onDidClose(() => view.dispose())); + store.add(ownerWindow.onWillLoad(event => { + if (event.reason === LoadReason.LOAD || (event.reason === LoadReason.RELOAD && view.presentation)) { + view.dispose(); + } else if (event.reason === LoadReason.RELOAD) { + view.setVisible(false); + } + })); + if (view.presentation) { + store.add(ownerWindow.onDidDestroy(() => view.dispose())); + if (ownerWindow.win) { + store.add(Event.fromNodeEventEmitter(ownerWindow.win.webContents, 'render-process-gone')(() => view.dispose())); + } + } + return store; +} diff --git a/src/vs/platform/browserView/test/common/browserView.test.ts b/src/vs/platform/browserView/test/common/browserView.test.ts index 333f75c2327c3..0976ff033a1e8 100644 --- a/src/vs/platform/browserView/test/common/browserView.test.ts +++ b/src/vs/platform/browserView/test/common/browserView.test.ts @@ -6,11 +6,65 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { BrowserViewStorageScope, getAgentBrowserViewCreationDefaults, isBrowserViewAssociatedResourceNavigation, isBrowserViewStorageScopeShareableWithAgent, isInMemoryStorageScope, matchesBrowserViewAudience } from '../../common/browserView.js'; +import { BrowserViewStorageScope, externalBrowserViewStorageAffinity, getAgentBrowserViewCreationDefaults, IBrowserViewCreateOptions, IBrowserViewInfo, isBrowserViewAssociatedResourceNavigation, isBrowserViewStorageScopeShareableWithAgent, isExternalCanvasLinkAllowed, isInMemoryStorageScope, matchesBrowserViewAudience, snapBrowserViewBounds, validateBrowserViewReuse, validateExternalBrowserViewOptions } from '../../common/browserView.js'; +import { upcastPartial } from '../../../../base/test/common/mock.js'; suite('BrowserView', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('external canvas links are limited to web and mail applications, without URL userinfo', () => { + assert.deepStrictEqual([ + 'https://example.test/docs', 'http://127.0.0.1:9000/link', 'mailto:hello@example.test', + 'https://user:password@example.test', 'javascript:alert(1)', 'file:///private/file', 'vscode://extension/action', 'https:/missing-host', + ].map(isExternalCanvasLinkAllowed), [true, true, true, false, false, false, false, false]); + }); + + test('external presentation identity survives enumeration and rejects ordinary or foreign-window reuse', () => { + const resource = URI.parse('test-canvas:/authority/session/chat/instance'); + const options: IBrowserViewCreateOptions = { + presentation: { type: 'external', resource }, + host: { windowId: 1 }, + owner: { type: 'user' }, + session: { scope: BrowserViewStorageScope.Agent, affinity: externalBrowserViewStorageAffinity(resource) }, + initialAudiences: [], + }; + const enumerated = upcastPartial({ id: 'view', presentation: options.presentation, host: options.host }); + validateExternalBrowserViewOptions(options); + assert.throws(() => validateExternalBrowserViewOptions(options, [enumerated]), /already attached/); + validateExternalBrowserViewOptions(options, [{ presentation: { type: 'external', resource: URI.parse('test-canvas:/another-owner') } }]); + validateBrowserViewReuse(enumerated, options); + assert.throws(() => validateBrowserViewReuse(enumerated, { ...options, presentation: undefined }), /presentation/); + assert.throws(() => validateBrowserViewReuse(enumerated, { ...options, host: { windowId: 2 } }), /owning window/); + assert.throws(() => validateBrowserViewReuse(enumerated, { ...options, presentation: { type: 'external', resource: URI.parse('test-canvas:/other') } }), /presentation/); + }); + + test('external pages require explicit isolated storage without wildcard audiences', () => { + const resource = URI.parse('test-canvas:/authority/session/chat/instance'); + const options: IBrowserViewCreateOptions = { + presentation: { type: 'external', resource }, + host: { windowId: 1 }, owner: { type: 'user' }, initialAudiences: [], + session: { scope: BrowserViewStorageScope.Agent, affinity: externalBrowserViewStorageAffinity(resource) }, + }; + for (const override of [ + { session: { scope: BrowserViewStorageScope.Global } }, + { session: { scope: BrowserViewStorageScope.Workspace } }, + { session: { scope: BrowserViewStorageScope.Agent } }, + { session: 'agent:another-instance' }, + { initialAudiences: undefined }, + { initialAudiences: [{ type: 'agent' as const }] }, + { owner: { type: 'agent' as const, sessionId: 'chat' } }, + ]) { + assert.throws(() => validateExternalBrowserViewOptions({ ...options, ...override }), /isolated storage/); + } + }); + + test('native layout snaps the absolute origin as well as its size at fractional zoom', () => { + assert.deepStrictEqual( + snapBrowserViewBounds({ x: 10.3, y: 20.7, width: 400.3, height: 250.8 }, 1.25), + { x: 9.6, y: 20, width: 400, height: 250.4 }, + ); + }); + test('allows navigation within an associated resource', () => { const associatedResource = URI.file('/workspace/index.html'); diff --git a/src/vs/platform/browserView/test/common/browserViewGroup.test.ts b/src/vs/platform/browserView/test/common/browserViewGroup.test.ts index cb87d19ef8d87..9fa85fdad8fd9 100644 --- a/src/vs/platform/browserView/test/common/browserViewGroup.test.ts +++ b/src/vs/platform/browserView/test/common/browserViewGroup.test.ts @@ -6,10 +6,20 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { matchesBrowserViewGroupFilter } from '../../common/browserViewGroup.js'; +import { URI } from '../../../../base/common/uri.js'; suite('BrowserViewGroup', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('external presentations are excluded even by an explicit browser id or wildcard audience', () => { + const presentation = { type: 'external' as const, resource: URI.parse('test-canvas:/instance') }; + assert.deepStrictEqual({ + explicitId: matchesBrowserViewGroupFilter('canvas', [], { browserIds: ['canvas'] }, presentation), + wildcard: matchesBrowserViewGroupFilter('canvas', [{ type: 'agent' }], { audience: { type: 'agent' } }, presentation), + ordinary: matchesBrowserViewGroupFilter('browser', [], { browserIds: ['browser'] }), + }, { explicitId: false, wildcard: false, ordinary: true }); + }); + test('matches browser IDs and audiences', () => { const sessionAudience = [{ type: 'agent', sessionId: 'session' }] as const; const allAgentsAudience = [{ type: 'agent' }] as const; diff --git a/src/vs/platform/browserView/test/electron-main/browserSessionFileAccess.test.ts b/src/vs/platform/browserView/test/electron-main/browserSessionFileAccess.test.ts new file mode 100644 index 0000000000000..31a4a0ceb3fc2 --- /dev/null +++ b/src/vs/platform/browserView/test/electron-main/browserSessionFileAccess.test.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise } from '../../../../base/common/async.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { BrowserSessionFileAccess } from '../../electron-main/browserSessionFileAccess.js'; + +suite('BrowserSessionFileAccess', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const workspace = URI.file('/canvas-file-trust/workspace'); + const external = URI.file('/canvas-file-trust/explicitly-trusted'); + const outside = URI.file('/canvas-file-trust/private/index.html').toString(); + + test('starts denied and leaves non-file network policy independent', () => { + const access = new BrowserSessionFileAccess(); + assert.deepStrictEqual({ + file: access.getError(outside), + http: access.getError('https://example.com/canvas'), + empty: access.getError(''), + }, { + file: { url: outside, errorCode: -10, errorDescription: 'ERR_ACCESS_DENIED', fileAccessDenied: true }, + http: undefined, + empty: undefined, + }); + }); + + test('trusted workspace and explicitly trusted outside folders include relative assets, not sibling prefixes', () => { + const access = new BrowserSessionFileAccess(); + access.setTrustedFileRoots([workspace.fsPath, external.fsPath], false); + const page = URI.joinPath(workspace, 'index.html').toString(); + const urls = [ + page, + new URL('./assets/app.js', page).href, + new URL('./assets/theme%20dark.css', page).href, + URI.joinPath(external, 'index.html').toString(), + URI.joinPath(external, 'images/icon.svg').toString(), + new URL('../private/index.html', page).href, + new URL('../workspace-other/index.html', page).href, + 'file:///canvas-file-trust/workspace/%2e%2e/private/index.html', + ]; + assert.deepStrictEqual(urls.map(url => access.isAllowed(url)), [true, true, true, true, true, false, false, false]); + }); + + test('denied protocol requests fail instead of rendering a successful forbidden page', async () => { + const access = new BrowserSessionFileAccess(); + let forwarded = false; + const response = await access.handleRequest(new Request(outside), async () => { + forwarded = true; + return new Response('must not be read'); + }); + assert.deepStrictEqual({ forwarded, type: response.type, status: response.status, body: await response.text() }, { + forwarded: false, type: 'error', status: 0, body: '', + }); + }); + + test('allowed file forwarding preserves response content and errors', async () => { + const access = new BrowserSessionFileAccess(); + access.setTrustedFileRoots([external.fsPath], false); + const url = URI.joinPath(external, 'assets/theme.css').toString(); + const response = new Response('body { color: blue; }', { headers: { 'content-type': 'text/css' } }); + const result = await access.handleRequest(new Request(url), async () => response); + const failure = new Error('Controlled missing file'); + await assert.rejects(access.handleRequest(new Request(url), async () => { throw failure; }), error => error === failure); + assert.deepStrictEqual({ sameResponse: result === response, content: await result.text() }, { + sameResponse: true, content: 'body { color: blue; }', + }); + }); + + test('revocation blocks the next request and only an explicit new root grant restores access', async () => { + const access = new BrowserSessionFileAccess(); + const url = URI.joinPath(external, 'index.html').toString(); + access.setTrustedFileRoots([workspace.fsPath, external.fsPath], false); + const before = access.isAllowed(url); + access.setTrustedFileRoots([workspace.fsPath], false); + let reads = 0; + const denied = await access.handleRequest(new Request(url), async () => { + reads++; + return new Response('unexpected'); + }); + access.setTrustedFileRoots([workspace.fsPath, external.fsPath], false); + assert.deepStrictEqual({ before, denied: denied.type, reads, granted: access.isAllowed(url) }, { + before: true, denied: 'error', reads: 0, granted: true, + }); + }); + + test('revocation also cancels an in-flight response before delivering its body', async () => { + const access = new BrowserSessionFileAccess(); + const response = new DeferredPromise(); + const url = URI.joinPath(external, 'index.html').toString(); + access.setTrustedFileRoots([external.fsPath], false); + const pending = access.handleRequest(new Request(url), () => response.p); + access.setTrustedFileRoots([], false); + let cancelled = false; + await response.complete(new Response(new ReadableStream({ cancel: () => { cancelled = true; } }))); + const result = await pending; + assert.deepStrictEqual({ cancelled, type: result.type }, { cancelled: true, type: 'error' }); + }); + + test('the existing explicit Workspace Trust disable flag can be removed again', () => { + const access = new BrowserSessionFileAccess(); + access.setTrustedFileRoots([], true); + const disabled = access.isAllowed(outside); + access.setTrustedFileRoots([workspace.fsPath], false); + assert.deepStrictEqual({ disabled, reenabled: access.isAllowed(outside) }, { disabled: true, reenabled: false }); + }); +}); diff --git a/src/vs/platform/browserView/test/electron-main/browserViewAccessibility.test.ts b/src/vs/platform/browserView/test/electron-main/browserViewAccessibility.test.ts new file mode 100644 index 0000000000000..9503b341caa08 --- /dev/null +++ b/src/vs/platform/browserView/test/electron-main/browserViewAccessibility.test.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { AXNode } from '../../../webContentExtractor/electron-main/cdpAccessibilityDomain.js'; +import { formatBrowserViewAccessibility } from '../../electron-main/browserViewAccessibility.js'; + +suite('BrowserView user accessibility', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function node(id: string, role: string, name: string): AXNode { + return { nodeId: id, ignored: false, role: { type: 'role', value: role }, name: { type: 'computedString', value: name } }; + } + + test('retains actual semantic content and states without URL metadata or duplicate inline text', () => { + const nodes: AXNode[] = [ + { ...node('1', 'RootWebArea', 'Private URL title'), properties: [{ name: 'url', value: { type: 'string', value: 'http://localhost/?token=private' } }] }, + node('2', 'heading', 'Synthetic counter'), + node('3', 'StaticText', 'Count: 4'), + node('4', 'InlineTextBox', 'Count: 4'), + { ...node('5', 'button', 'Increment'), properties: [{ name: 'disabled', value: { type: 'boolean', value: false } }] }, + { ...node('6', 'StaticText', 'Hidden'), ignored: true }, + ]; + assert.deepStrictEqual(formatBrowserViewAccessibility(nodes), { + scope: 'main-frame', truncated: false, + text: 'heading: Synthetic counter\nStaticText: Count: 4\nbutton: Increment (disabled=false)', + }); + }); + + test('bounds output and does not invent a description of graphical content', () => { + assert.deepStrictEqual({ + graphical: formatBrowserViewAccessibility([node('1', 'Canvas', '')]), + oversized: formatBrowserViewAccessibility([node('2', 'StaticText', 'x'.repeat(32769))]), + }, { + graphical: { scope: 'main-frame', text: '', truncated: false }, + oversized: { scope: 'main-frame', text: '', truncated: true }, + }); + }); +}); diff --git a/src/vs/platform/browserView/test/electron-main/browserViewContextMenu.test.ts b/src/vs/platform/browserView/test/electron-main/browserViewContextMenu.test.ts new file mode 100644 index 0000000000000..846e06873f82c --- /dev/null +++ b/src/vs/platform/browserView/test/electron-main/browserViewContextMenu.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * 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 type { KeyboardEvent, MenuItem, MenuItemConstructorOptions } from 'electron'; +import { timeout } from '../../../../base/common/async.js'; +import { URI } from '../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { createBrowserViewExternalLinkMenuItem } from '../../electron-main/browserViewContextMenu.js'; + +suite('BrowserView external-link context menu', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const presentation = { type: 'external', resource: URI.parse('test-canvas:/owner/chat/instance') } satisfies NonNullable[0]>; + + function click(item: MenuItemConstructorOptions): void { + item.click?.(upcastPartial({}), undefined, upcastPartial({})); + } + + test('unsafe canvas links are disabled and their callbacks cannot invoke the external opener', () => { + const opened: string[] = []; + const blocked: string[] = []; + const log = store.add(new class extends NullLogService { + override warn(message: string): void { blocked.push(message); } + }()); + const urls = ['file:///private/canvas/index.html', 'vscode://publisher.extension/action', 'custom-app:action']; + const enabled = urls.map(url => { + const item = createBrowserViewExternalLinkMenuItem(presentation, url, async target => { opened.push(target); return true; }, log); + click(item); + return item.enabled; + }); + assert.deepStrictEqual({ enabled, opened, blocked: blocked.length }, { enabled: [false, false, false], opened: [], blocked: 3 }); + }); + + test('allowed canvas links preserve the exact user-selected target', () => { + const opened: string[] = []; + const log = store.add(new NullLogService()); + const urls = ['https://example.com/trace', 'http://127.0.0.1:3000/help', 'mailto:help@example.com']; + const enabled = urls.map(url => { + const item = createBrowserViewExternalLinkMenuItem(presentation, url, async target => { opened.push(target); return true; }, log); + click(item); + return item.enabled; + }); + assert.deepStrictEqual({ enabled, opened }, { enabled: [true, true, true], opened: urls }); + }); + + test('ordinary browser links retain their existing external-scheme behavior', () => { + const opened: string[] = []; + const log = store.add(new NullLogService()); + const urls = ['file:///private/example.html', 'vscode://publisher.extension/action', 'custom-app:action']; + const enabled = urls.map(url => { + const item = createBrowserViewExternalLinkMenuItem(undefined, url, async target => { opened.push(target); return true; }, log); + click(item); + return item.enabled; + }); + assert.deepStrictEqual({ enabled, opened }, { enabled: [true, true, true], opened: urls }); + }); + + test('an external opener failure is reported without retrying', async () => { + const failure = new Error('Controlled external opener failure'); + const errors: Error[] = []; + let attempts = 0; + const log = store.add(new class extends NullLogService { + override error(_message: string, error: Error): void { errors.push(error); } + }()); + click(createBrowserViewExternalLinkMenuItem(presentation, 'https://example.com', async () => { attempts++; throw failure; }, log)); + await timeout(0); + assert.deepStrictEqual({ attempts, errors }, { attempts: 1, errors: [failure] }); + }); +}); diff --git a/src/vs/platform/browserView/test/electron-main/browserViewWindowLifecycle.test.ts b/src/vs/platform/browserView/test/electron-main/browserViewWindowLifecycle.test.ts new file mode 100644 index 0000000000000..780423b8bf82a --- /dev/null +++ b/src/vs/platform/browserView/test/electron-main/browserViewWindowLifecycle.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { EventEmitter } from 'events'; +import { Emitter } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ICodeWindow, ILoadEvent, LoadReason } from '../../../window/electron-main/window.js'; +import type { BrowserView } from '../../electron-main/browserView.js'; +import { registerBrowserViewWindowLifecycle } from '../../electron-main/browserViewWindowLifecycle.js'; + +suite('BrowserViewWindowLifecycle', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createFixture(external: boolean) { + const load = store.add(new Emitter()); + const close = store.add(new Emitter()); + const destroy = store.add(new Emitter()); + const renderer = new EventEmitter(); + const webContents: Electron.WebContents = upcastPartial({ + on: (event, listener) => { renderer.on(event, listener); return webContents; }, + removeListener: (event, listener) => { renderer.removeListener(event, listener); return webContents; }, + }); + const owner = upcastPartial({ + onWillLoad: load.event, onDidClose: close.event, onDidDestroy: destroy.event, + win: upcastPartial({ webContents }), + }); + let disposed = false; + let visible = true; + const lifetime = store.add(new DisposableStore()); + const view = new class extends mock() { + override readonly presentation = external ? { type: 'external' as const, resource: URI.parse('test-canvas:/instance') } : undefined; + override setVisible(value: boolean) { visible = value; } + override dispose() { disposed = true; lifetime.dispose(); } + }(); + lifetime.add(registerBrowserViewWindowLifecycle(owner, view)); + return { view, load, close, destroy, renderer, state: () => ({ disposed, visible }) }; + } + + test('reload retires external pages while retaining ordinary browser pages for enumeration', () => { + const external = createFixture(true); + const ordinary = createFixture(false); + external.load.fire({ reason: LoadReason.RELOAD, workspace: undefined }); + ordinary.load.fire({ reason: LoadReason.RELOAD, workspace: undefined }); + assert.deepStrictEqual({ + external: external.state(), ordinary: ordinary.state(), + externalRendererListeners: external.renderer.listenerCount('render-process-gone'), + }, { external: { disposed: true, visible: true }, ordinary: { disposed: false, visible: false }, externalRendererListeners: 0 }); + }); + + test('hiding retains content but owner renderer loss releases it and its listeners', () => { + const fixture = createFixture(true); + fixture.view.setVisible(false); + const hidden = fixture.state(); + fixture.renderer.emit('render-process-gone'); + assert.deepStrictEqual({ + hidden, afterCrash: fixture.state(), + listeners: [fixture.load.hasListeners(), fixture.close.hasListeners(), fixture.destroy.hasListeners(), fixture.renderer.listenerCount('render-process-gone')], + }, { hidden: { disposed: false, visible: false }, afterCrash: { disposed: true, visible: false }, listeners: [false, false, false, 0] }); + }); +}); diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index dbeaac3640536..29aa734d710fc 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -123,6 +123,12 @@ Sessions may expose the artifacts and references recorded by the agent. Both sha Providers may advertise `supportsRemoveArtifacts` and implement `removeSessionArtifact`. User-initiated removal routes through `ISessionsManagementService` to the owning provider, which persists and publishes the updated artifact list. Removing a record does not remove independent session associations or alter the linked resource. +### Canvases + +Provider-owned application instances are exposed separately from artifacts through the optional `ISessionsProvider.getSessionCanvases(sessionId, chat)` facet. Management verifies the exact session/chat pair before routing it, without a main-chat fallback. The observable facade separates live declarations, logical membership, and full instance state; provider-specific resource translation and execution remain in the provider. + +Canvas editors persist only provider/session/chat/member references. Presentation leases follow the represented owner and visibility; disposing an editor or restoring a working set does not logically close a canvas or execute its provider. The owning [canvas contribution](contrib/canvases/README.md) specifies explicit close/recovery, source resolution, and native isolation. + ## Provider contract `ISessionsProvider` is defined in `services/sessions/common/sessionsProvider.ts`. A provider represents one compute environment. A provider may advertise multiple session types, and multiple providers may advertise the same logical type. @@ -145,7 +151,11 @@ A provider that must establish backend state before presenting a session may imp ### Drafts -`createNewSession` and `createQuickChat` return untitled drafts. A draft remains `Untitled` while its first request is prepared; `isNewSessionRequestInProgress` separately lets the UI present that activity without treating the session as committed. Draft preparation receives the first query so a provider can materialize query-dependent execution state before replacing the draft. A draft enters the committed catalog when its first request is sent. The management service owns the currently presented draft; the provider owns its backend resources. `deleteNewSession` disposes an abandoned draft. +`createNewSession` and `createQuickChat` return untitled drafts. A draft remains `Untitled` while its first request is prepared; `isNewSessionRequestInProgress` separately lets the UI present that activity without treating the session as committed. Draft preparation receives the first query so a provider can materialize query-dependent execution state before replacing the draft. A draft enters the committed catalog when its first request is sent. A canvas-capable provider may also commit a draft when its backend publishes a ready session with durable canvas membership or retained execution intent, without a conversation turn. Explicit provider initialization can retain an owner before any canvas is opened; an empty catalog alone cannot. The management service owns the currently presented draft; the provider owns its backend resources. `deleteNewSession` disposes an abandoned draft. + +Replacing a pending draft with a committed facade releases the management service's draft pointer without discarding the backend owner. The visible session and its working set follow the ordinary replacement lifecycle, including same-resource canvas-first promotion. + +Within a committed session, authoritative canvas membership likewise makes its owning chat non-empty. Providers must not continue advertising that chat as an untitled draft eligible for reuse. Automation editing uses an independent draft so it cannot replace the ordinary New Session composer. Providers advertise `supportsAutomationSessionConfiguration` when they restore `ISessionsProviderCreateSessionOptions.automationConfiguration` before the draft's first configuration resolution and implement `getAutomationSessionConfiguration` to capture the current template. The management service rejects canonical templates for providers without this capability, while deprecated flat aliases continue through ordinary model, mode, and permission operations. It distinguishes unsupported capture from a valid empty template, a replaced draft, and capture failure. diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 7736a997f6708..8a26c6071adf1 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -38,6 +38,7 @@ export const Menus = { /** Header actions of the test custom view. */ CustomViewTest: new MenuId('SessionsCustomViewTest'), + Canvas: new MenuId('SessionsCanvas'), /** Header actions of the Automations custom view. */ CustomViewAutomations: new MenuId('SessionsCustomViewAutomations'), diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 53214ebd6ddbf..2a6b9e81d9eb1 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -18,6 +18,7 @@ export const SessionHasGitRepositoryContext = new RawContextKey('sessio export const SessionUsesCombinedConfigPickerContext = new RawContextKey('sessionUsesCombinedConfigPicker', false, localize('sessionUsesCombinedConfigPicker', "Whether the session's provider offers a combined mode and model configuration picker (used on phone layouts in place of the standalone pickers)")); export const SessionSupportsRenameContext = new RawContextKey('sessionSupportsRename', false, localize('sessionSupportsRename', "Whether the session can be renamed")); export const SessionSupportsDeleteContext = new RawContextKey('sessionSupportsDelete', false, localize('sessionSupportsDelete', "Whether the session can be deleted")); +export const SessionSupportsCanvasesContext = new RawContextKey('sessionSupportsCanvases', false, localize('sessionSupportsCanvases', "Whether the session supports live canvases")); //#endregion diff --git a/src/vs/sessions/contrib/canvases/README.md b/src/vs/sessions/contrib/canvases/README.md new file mode 100644 index 0000000000000..13bef38035958 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/README.md @@ -0,0 +1,82 @@ +# Session canvases + +Canvases are provider-owned application instances, not session artifacts or ordinary browser editors. The Sessions contribution owns their **presentation**, while the provider owns membership, application execution, source admission, and recovery. + +## Availability and ownership + +The desktop preview is opt-in through `sessions.experimental.canvases.enabled`, which defaults to `false`. This is a presentation preference, not an execution permission or a replacement for managed runtime settings. AI hiding, the represented session's observable `supportsCanvases` capability, and actual connection negotiation still apply. Workbench-only providers, other agent types, remote execution, and Web are not enabled by this contribution. + +`ISessionsProvider.getSessionCanvases(sessionId, chat)` is an optional, provider-neutral facet. `ISessionsManagementService.getSessionCanvases(session, chat)` verifies the supplied chat belongs to that exact session and routes through its provider; it never substitutes the main chat. `ISessionCanvases` exposes: + +- observable live type declarations, logical entries, membership hydration, availability, and read errors; +- separately negotiated executable-registry initialization and its outstanding-operation state; +- a full-state subscription for a selected member; +- read-only catalog refresh and source resolution; +- explicit registry initialization, open, action, logical close, and provider restart operations. + +Backend resource translation stays in the provider. Mutable catalog and full-state data are observable, not mirrored through a second UI event protocol. A connection replacement invalidates subscriptions and pending reads even if its local service object is reused. + +## Logical editors and native leases + +`SessionCanvasInput` persists only a `vscode-session-canvas` reference containing the provider identifier, session resource, chat resource, and canvas resource. Its serializer never retains source URLs, credentials, native view identifiers, executable input, or effect requests. Restoration resolves that reference and reads a **fresh** source; it does not open a provider or replay an action. + +`SessionCanvasMount` holds a presentation lease only while its editor is visible and its exact session/chat is the represented owner. The lease is independent of working-set timing, including workspace transitions and multi-session layouts. Source pulls are fenced by connection generation, incarnation, revision, and per-pull ordering; a late credential refresh cannot replace a newer source for the same state. Native creation is serialized across mounting lifetimes, and superseded creations are disposed. + +The shared native host receives only the editor's assigned content bounds. It must not take the allocation belonging to Details or change the layout controller's visibility rules. + +| User operation | Meaning | +| --- | --- | +| **Initialize Canvas Providers** | Explicitly initialize the exact chat's registry through normal source/permission admission. No type identity or conversation turn is required. | +| Tab **X**, Hide Editor, working-set swap, or whole-side-pane hiding | Detach and release native presentation resources. Do not change provider membership or domain files. | +| Show an existing canvas | Reattach from current state and a fresh source pull. | +| **Reload View** | Reread state/source, including retrying a failed state subscription. Do not start/restart providers or replay effects. | +| **Close Canvas** | One revision-guarded logical close through the owning provider. | +| **Restart Canvas Provider** | Explicit incarnation-guarded provider/chat recovery; may replace every instance sharing that provider. Broader owned-runtime recovery requires separate approval of its resident-chat impact. | + +Editor group/window transfers are rejected. Main-process creation also rejects a second concurrent external view for the same logical resource. Native handles and storage authority must never be cloned as a way to move an editor. + +## Entry points and targeting + +`Canvases…` appears in the represented session toolbar and the supported Add Tab menu. It displays live types and current members and accepts structured JSON open input, with provider-side schema validation. An empty live catalog is not a provider-start operation. + +When the host separately advertises explicit initialization, a cold catalog offers **Initialize Canvas Providers**. Selecting it closes the picker before execution approval can appear, then uses cancellable progress for the captured chat. Success rereads the live catalog and returns to the picker only if that same chat is still represented. Cancellation, connection replacement, and owner disposal invalidate the pending operation; an indeterminate result is surfaced rather than retried. Browsing, refreshing, and reconnecting never initialize providers. + +The Agent Host projection uses the negotiated `vscode/initializeCanvasChat` extension operation and its matching cancellation route. This is an explicit VS Code initialization effect, not a change to the six canonical AHP canvas routes or an execution-permission override. The facade's `initialized` property describes membership hydration; it must not be interpreted as provider startup or approval. + +Command identifiers: + +- `workbench.action.sessions.canvas.manage` +- `workbench.action.sessions.canvas.reloadView` +- `workbench.action.sessions.canvas.close` +- `workbench.action.sessions.canvas.restartProvider` +- `workbench.action.sessions.canvas.accessibleView` + +Session controls capture `ISessionContext` or an explicit session/chat pair before asynchronous work. Editor controls capture their logical input. Restart confirmation retains the entry observed before the dialog. Native tool publication can reveal new members for the current visible owner with conversation focus preserved; publication for background owners does not switch sessions. Their members remain available for an explicit later reveal. + +### Canvas-first owners + +The ordinary `ISessionsService.openNewSession` route creates and presents a real provider draft. `ISessionCanvasService.getTarget` captures that session and its exact chat; `open` accepts canonical `SessionCanvasOpenOptions`, including a known source/type identity when the live catalog is cold. This is the same six-route transport used by the picker, not a synthetic-owner command or a model turn. Pure catalog refresh does not initialize missing providers. + +Open and explicit provider initialization wait for eager AHP owner creation. A ready backend session with authoritative canvas membership or retained execution intent and an actual published session summary graduates through the normal session-replacement lifecycle. Explicit initialization can retain an owner without opening a member; dismissing the picker or navigating away must not discard that owner. The same logical references and collection survive, the pending-draft pointer is released without discarding the owner, and subsequent navigation uses real session working sets. Pending or selected Dev Container execution remains unsupported and does not retain a local canvas-opening capability. + +A previously untitled peer chat also adopts its backend status once ready state publishes its own canvas membership. It is no longer an empty composer that can be reused for a different conversation. + +## Native isolation and guest behavior + +The native view has external presentation metadata `{ type: 'external', resource }`, user-only ownership, no automation audiences, and authority-qualified in-memory storage. It does not create a `BrowserEditorInput`, extension browser API object, or model/CDP target. Ordinary browser enumeration and browser editors remain independent. + +The guest receives the extension theme contract as main-frame-only, value-only defaults: semantic CSS variables, the `rampa` stylesheet, root/body theme attributes, and pointer-hover treatment. VS Code colors, syntax metadata, high-contrast outlines, and typography feed the mapping. Default styles precede application styles; explicit application CSS and changed attributes retain precedence. This does not expose a privileged guest bridge, Node, ambient browser cookies, or automatic model sharing. + +HTTP, HTTPS, and file sources are admitted by the presentation parser; it does not impose a loopback-only subset. Native network policy still applies. The first-release file contract is **trusted file resources**, including relative assets inside the existing browser trusted-folder allowlist and explicitly trusted folders outside the current workspace. It is not arbitrary filesystem access. External presentation waits for Workspace Trust initialization and native configuration acknowledgement before allocating the page. + +An untrusted file is a failed native navigation, not a successfully loaded denial page. The shared native host offers **Trust Folder...** through the existing Workspace Trust resource dialog, **Manage Workspace Trust**, and an explicit **Reload**. A folder grant applies to Workspace Trust throughout VS Code; execution-source approval does **not** grant file access or set `trustAllFiles`. Native presentation identity, user-only ownership, empty automation audiences, isolated storage, and the existing behavior when Workspace Trust is disabled remain independent. + +Removing folder trust blocks subsequent requests and responses still awaiting admission, hides affected native content, and forces a denied reload without showing stale screenshots. Other trusted views are unaffected. A later grant alone does not replay navigation or provider effects: recovery reloads only the captured native page. Missing trusted files remain load failures rather than trust prompts. Revocation cannot erase data already read by an application or revoke its separate extension Node execution authority. + +Eligible user-initiated HTTP/HTTPS/mail links can open externally, but external canvas popups never create native browser children. There is no click interception that overrides application `preventDefault`. + +Accessible View and Help use the existing accessibility providers and verbosity controls. Semantic reading is user-requested, bounded, main-frame HTML inspection; it is not an agent tool or an inferred description of graphical content. Focus returns only to the still-represented input, and owner/navigation changes invalidate stale semantic snapshots. Native page sandboxing does not sandbox extension Node execution. + +## Validation boundary + +Focused tests cover provider-neutral routing, connection and source races, command target capture, native isolation, and real layout-controller working-set/Hide Editor/side-pane lifecycles. They do not certify arbitrary extension compatibility, universal screen-reader behavior, graphical content accessibility, or a Chromium process/memory budget. Live runtime admission, cold initialization, disconnected shared-runtime recovery, and native guest behavior require the corresponding integrated runtime/native qualification. diff --git a/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts b/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts new file mode 100644 index 0000000000000..960bd38b681b5 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/common/sessionCanvas.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../base/common/codicons.js'; +import type { IReference } from '../../../../base/common/lifecycle.js'; +import type { IObservable, IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { createDecorator, type IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { EditorInputCapabilities, type IEditorSerializer, type IUntypedEditorInput } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { SessionCanvasUri, type CanvasEntry, type ISessionCanvasReference, type ISessionCanvases, type SessionCanvasOpenOptions } from '../../../services/sessions/common/sessionCanvases.js'; +import type { IChat, ISession } from '../../../services/sessions/common/session.js'; +import type { SessionCanvasPresentation } from './sessionCanvasPresentation.js'; + +export interface ISessionCanvasTarget { + readonly session: ISession; + readonly chat: IChat; + readonly canvases: ISessionCanvases; +} + +export const ISessionCanvasService = createDecorator('sessionCanvasService'); + +export interface ISessionCanvasService { + readonly _serviceBrand: undefined; + readonly enabled: IObservable; + getTarget(session: URI, chat: URI, reader?: IReader): ISessionCanvasTarget | undefined; + getInput(resource: URI): SessionCanvasInput; + isVisibleOwner(reference: ISessionCanvasReference, reader?: IReader): boolean; + isClosing(reference: ISessionCanvasReference, reader?: IReader): boolean; + acquirePresentation(input: SessionCanvasInput, windowId: number): IReference | undefined; + open(target: ISessionCanvasTarget, options: SessionCanvasOpenOptions): Promise; + reveal(target: ISessionCanvasTarget, canvas: CanvasEntry, preserveFocus?: boolean): Promise; + close(reference: ISessionCanvasReference): Promise; + restart(reference: ISessionCanvasReference): Promise; + reload(reference: ISessionCanvasReference): void; +} + +export class SessionCanvasInput extends EditorInput { + static readonly ID = 'sessions.editorInput.canvas'; + static readonly EDITOR_ID = 'sessions.editor.canvas'; + readonly reference: ISessionCanvasReference; + private title = localize('canvas.editorName', "Canvas"); + + constructor(override readonly resource: URI) { + super(); + const reference = SessionCanvasUri.parse(resource); + if (!reference) { + throw new Error('Invalid logical canvas reference.'); + } + this.reference = reference; + } + + override get typeId(): string { return SessionCanvasInput.ID; } + override get editorId(): string { return SessionCanvasInput.EDITOR_ID; } + override get capabilities(): EditorInputCapabilities { return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal; } + override getName(): string { return this.title; } + setTitle(title: string): void { + if (this.title !== title) { + this.title = title; + this._onDidChangeLabel.fire(); + } + } + override getDescription(): string { return localize('canvas.editorDescription', "Closing this tab hides the view. Use Close Canvas to remove it from the chat."); } + override getIcon() { return Codicon.preview; } + override canMove(): string { return localize('canvas.cannotMove', "Canvas views cannot be moved between editor groups or windows. Reopen the view beside its owning conversation."); } + override matches(other: EditorInput | IUntypedEditorInput): boolean { + return other instanceof SessionCanvasInput ? isEqual(this.resource, other.resource) : super.matches(other); + } + override toUntyped(): IUntypedEditorInput { + return { resource: this.resource, options: { override: SessionCanvasInput.EDITOR_ID } }; + } +} + +export class SessionCanvasSerializer implements IEditorSerializer { + constructor(@ISessionCanvasService private readonly canvasService: ISessionCanvasService) { } + canSerialize(input: EditorInput): boolean { return input instanceof SessionCanvasInput; } + serialize(input: EditorInput): string | undefined { + return input instanceof SessionCanvasInput ? JSON.stringify({ version: 1, resource: input.resource.toString() }) : undefined; + } + deserialize(_instantiationService: IInstantiationService, serialized: string): EditorInput | undefined { + try { + const value: unknown = JSON.parse(serialized); + const descriptor: { version?: unknown; resource?: unknown } = value && typeof value === 'object' ? value : {}; + if (descriptor.version === 1 && typeof descriptor.resource === 'string' && SessionCanvasUri.parse(URI.parse(descriptor.resource, true))) { + return this.canvasService.getInput(URI.parse(descriptor.resource, true)); + } + } catch { + // Invalid logical references are not executable restoration requests. + } + return undefined; + } +} diff --git a/src/vs/sessions/contrib/canvases/common/sessionCanvasMount.ts b/src/vs/sessions/contrib/canvases/common/sessionCanvasMount.ts new file mode 100644 index 0000000000000..93889c9d79cc8 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/common/sessionCanvasMount.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, observableValue, type IObservable } from '../../../../base/common/observable.js'; +import type { ISessionCanvasService, SessionCanvasInput } from './sessionCanvas.js'; +import type { SessionCanvasPresentation } from './sessionCanvasPresentation.js'; + +/** The editor's visibility/owner lease, independent of layout-specific working-set behavior. */ +export class SessionCanvasMount extends Disposable { + readonly presentation = observableValue(this, undefined); + + constructor( + service: ISessionCanvasService, + input: IObservable, + visible: IObservable, + windowId: number, + ) { + super(); + const eligibleInput = derived(this, reader => { + const current = input.read(reader); + const showing = visible.read(reader); + const owner = current && service.isVisibleOwner(current.reference, reader); + const closing = current && service.isClosing(current.reference, reader); + return current && showing && owner && !closing ? current : undefined; + }); + this._register(autorun(reader => { + const current = eligibleInput.read(reader); + const lease = current ? service.acquirePresentation(current, windowId) : undefined; + if (lease) { + reader.store.add(lease); + } + this.presentation.set(lease?.object, undefined); + })); + } + + override dispose(): void { + super.dispose(); + this.presentation.set(undefined, undefined); + } +} diff --git a/src/vs/sessions/contrib/canvases/common/sessionCanvasPresentation.ts b/src/vs/sessions/contrib/canvases/common/sessionCanvasPresentation.ts new file mode 100644 index 0000000000000..bbdc2237aa2e9 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/common/sessionCanvasPresentation.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Sequencer } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { autorun, derived, observableValue, transaction } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import type { IBrowserViewModel } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import { canvasIdentityEquals, CanvasAvailabilityStatus, CanvasTrustStatus, type CanvasEntry, type CanvasState, type ISessionCanvases } from '../../../services/sessions/common/sessionCanvases.js'; + +export type SessionCanvasPresentationStatus = 'loading' | 'attached' | 'empty' | 'unavailable' | 'unsupported' | 'pendingTrust' | 'blocked' | 'failed' | 'closed'; + +/** A single mounted presentation. Disposing it releases native resources, never logical membership. */ +export class SessionCanvasPresentation extends Disposable { + readonly model = observableValue(this, undefined); + readonly status = observableValue(this, 'loading'); + readonly entry = observableValue(this, undefined); + readonly liveState = observableValue(this, undefined); + private readonly nativeLifetime = this._register(new DisposableStore()); + private readonly mounts = new Sequencer(); + private readonly subscriptionGeneration = observableValue(this, 0); + private pullSequence = 0; + private observed: { generation: number; incarnation: string; revision: number } | undefined; + private presented: { generation: number; entry: CanvasEntry; url: string } | undefined; + + constructor( + private readonly canvases: ISessionCanvases, + readonly resource: string, + private readonly createModel: (url: string) => Promise, + ) { + super(); + const subscription = derived(this, reader => { + this.subscriptionGeneration.read(reader); + return reader.store.add(canvases.observeCanvas(resource)).object; + }); + this._register(autorun(reader => { + const generation = canvases.generation.read(reader); + const availability = canvases.availability.read(reader); + const initialized = canvases.initialized.read(reader); + const entry = canvases.entries.read(reader).find(entry => entry.resource === resource); + const currentSubscription = subscription.read(reader); + const state = currentSubscription.state.read(reader); + const error = currentSubscription.error.read(reader); + transaction(tx => { + this.entry.set(entry, tx); + this.liveState.set(state, tx); + }); + if (availability !== 'available') { + this.invalidate(availability === 'unsupported' ? 'unsupported' : 'unavailable'); + } else if (!entry) { + this.invalidate(initialized ? 'closed' : 'loading'); + } else if (entry.trust.status !== CanvasTrustStatus.Trusted) { + this.invalidate(entry.trust.status === CanvasTrustStatus.Pending ? 'pendingTrust' : 'blocked'); + } else if (entry.availability !== CanvasAvailabilityStatus.Ready && entry.availability !== CanvasAvailabilityStatus.Empty) { + this.invalidate(entry.availability === CanvasAvailabilityStatus.Loading ? 'loading' + : entry.availability === CanvasAvailabilityStatus.Unsupported ? 'unsupported' + : entry.availability === CanvasAvailabilityStatus.Failed ? 'failed' : 'unavailable'); + } else if (error) { + this.invalidate('failed'); + } else if (!state || state.resource !== entry.resource || !canvasIdentityEquals(state.identity, entry.identity) + || state.identity.incarnation !== entry.identity.incarnation || state.revision !== entry.revision || state.availability.status !== entry.availability) { + this.pullSequence++; + this.observed = undefined; + // The two channels can deliver a metadata revision separately. Keep an + // already authorized page, but never reuse a pull from the older revision. + if (!this.hasCurrentModel(entry, generation) || !state || state.resource !== entry.resource + || !canvasIdentityEquals(state.identity, entry.identity) || state.identity.incarnation !== entry.identity.incarnation + || state.trust.status !== CanvasTrustStatus.Trusted || state.availability.status !== entry.availability) { + this.retireModel(); + } + this.status.set('loading', undefined); + } else if (state.trust.status !== CanvasTrustStatus.Trusted) { + this.invalidate(state.trust.status === CanvasTrustStatus.Pending ? 'pendingTrust' : 'blocked'); + } else if (this.observed?.generation !== generation || this.observed.incarnation !== entry.identity.incarnation || this.observed.revision !== entry.revision) { + this.observed = { generation, incarnation: entry.identity.incarnation, revision: entry.revision }; + void this.pull(entry, generation); + } + })); + } + + private retireModel(): void { + this.nativeLifetime.clear(); + this.presented = undefined; + this.model.set(undefined, undefined); + } + + private hasCurrentModel(entry: CanvasEntry, generation: number): boolean { + return !!this.model.get() && this.presented?.generation === generation + && this.presented.entry.resource === entry.resource && canvasIdentityEquals(this.presented.entry.identity, entry.identity) + && this.presented.entry.identity.incarnation === entry.identity.incarnation; + } + + private invalidate(status: SessionCanvasPresentationStatus): void { + this.pullSequence++; + this.observed = undefined; + this.retireModel(); + this.status.set(status, undefined); + } + + private isCurrent(entry: CanvasEntry, generation: number, sequence: number): boolean { + const current = this.entry.get(); + return !this._store.isDisposed && sequence === this.pullSequence && generation === this.canvases.generation.get() + && this.canvases.availability.get() === 'available' && current?.resource === entry.resource + && canvasIdentityEquals(current.identity, entry.identity) + && current.identity.incarnation === entry.identity.incarnation && current.revision === entry.revision + && current.trust.status === CanvasTrustStatus.Trusted; + } + + private async pull(entry: CanvasEntry, generation: number): Promise { + const sequence = ++this.pullSequence; + if (!this.hasCurrentModel(entry, generation)) { + this.retireModel(); + } + this.status.set('loading', undefined); + try { + const result = await this.canvases.resolveSource(entry); + if (!this.isCurrent(entry, generation, sequence)) { + return; + } + if (result.incarnation !== entry.identity.incarnation || result.revision !== entry.revision) { + this.retireModel(); + this.status.set('unavailable', undefined); + return; + } + if (!result.source || (result.availability !== CanvasAvailabilityStatus.Ready && result.availability !== CanvasAvailabilityStatus.Empty)) { + this.retireModel(); + this.status.set(result.availability === CanvasAvailabilityStatus.Loading ? 'loading' + : result.availability === CanvasAvailabilityStatus.Empty ? 'empty' + : result.availability === CanvasAvailabilityStatus.Unsupported ? 'unsupported' + : result.availability === CanvasAvailabilityStatus.Failed ? 'failed' : 'unavailable', undefined); + return; + } + const url = validateCanvasPresentationUrl(result.source.url); + if (result.source.expiresAt && (!Number.isFinite(Date.parse(result.source.expiresAt)) || Date.parse(result.source.expiresAt) <= Date.now())) { + this.retireModel(); + this.status.set('unavailable', undefined); + return; + } + if (this.hasCurrentModel(entry, generation) && this.presented?.url === url) { + this.presented = { generation, entry, url }; + this.status.set(result.availability === CanvasAvailabilityStatus.Empty ? 'empty' : 'attached', undefined); + return; + } + await this.mounts.queue(async () => { + if (!this.isCurrent(entry, generation, sequence)) { + return; + } + this.retireModel(); + const model = await this.createModel(url); + if (!this.isCurrent(entry, generation, sequence)) { + model.dispose(); + return; + } + this.nativeLifetime.add(model); + this.nativeLifetime.add(Event.once(model.onWillDispose)(() => { + this.presented = undefined; + this.model.set(undefined, undefined); + this.status.set('unavailable', undefined); + })); + this.presented = { generation, entry, url }; + this.model.set(model, undefined); + this.status.set(result.availability === CanvasAvailabilityStatus.Empty ? 'empty' : 'attached', undefined); + }); + } catch { + if (this.isCurrent(entry, generation, sequence)) { + this.retireModel(); + this.status.set('failed', undefined); + } + } + } + + /** A fresh source pull and page load, without restarting a provider or repeating an open/action. */ + reload(): void { + this.retireModel(); + const entry = this.entry.get(); + if (entry && this.observed && entry.trust.status === CanvasTrustStatus.Trusted) { + void this.pull(entry, this.canvases.generation.get()); + } else { + this.subscriptionGeneration.set(this.subscriptionGeneration.get() + 1, undefined); + } + } + + override dispose(): void { + this.pullSequence++; + this.retireModel(); + super.dispose(); + } +} + +export function validateCanvasPresentationUrl(value: string): string { + const uri = URI.parse(value, true); + if (![Schemas.http, Schemas.https, Schemas.file].includes(uri.scheme) || uri.authority.includes('@') + || ((uri.scheme === Schemas.http || uri.scheme === Schemas.https) && !uri.authority)) { + throw new Error(localize('canvas.invalidSource', "The canvas source is not a supported HTTP, HTTPS, or trusted file resource.")); + } + return value; +} diff --git a/src/vs/sessions/contrib/canvases/electron-browser/media/sessionCanvas.css b/src/vs/sessions/contrib/canvases/electron-browser/media/sessionCanvas.css new file mode 100644 index 0000000000000..ac51367e22f5f --- /dev/null +++ b/src/vs/sessions/contrib/canvases/electron-browser/media/sessionCanvas.css @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.session-canvas-header { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size80); + padding: var(--vscode-spacing-size80); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body2); +} + +.session-canvas-header > span { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-canvas-actions { + flex-shrink: 0; +} + +.session-canvas-message { + padding: var(--vscode-spacing-size160); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-fontSize-body1); + white-space: normal; + overflow-wrap: anywhere; +} + +.session-canvas-editor .browser-container:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: -1px; +} diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts new file mode 100644 index 0000000000000..0811c7a237057 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasActions.ts @@ -0,0 +1,284 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../../base/common/errors.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, isObservable } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; +import { IQuickInputService, type IQuickPickItem, type QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js'; +import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import type { IChat } from '../../../services/sessions/common/session.js'; +import { CANVAS_INPUT_MAX_LENGTH, CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, SessionCanvasUri, type CanvasEntry, type CanvasTypeDeclaration, type ISessionCanvasReference } from '../../../services/sessions/common/sessionCanvases.js'; +import { ISessionCanvasService, SessionCanvasInput, type ISessionCanvasTarget } from '../common/sessionCanvas.js'; + +export const SessionCanvasCommands = { + manage: 'workbench.action.sessions.canvas.manage', + reload: 'workbench.action.sessions.canvas.reloadView', + close: 'workbench.action.sessions.canvas.close', + restart: 'workbench.action.sessions.canvas.restartProvider', + accessibleView: 'workbench.action.sessions.canvas.accessibleView', +} as const; + +type CanvasPick = IQuickPickItem & ( + | { readonly kind: 'type'; readonly declaration: CanvasTypeDeclaration } + | { readonly kind: 'instance'; readonly canvas: CanvasEntry } + | { readonly kind: 'initialize' } + | { readonly kind: 'refresh' } +); + +function entryDescription(canvas: CanvasEntry): string { + if (canvas.trust.status === CanvasTrustStatus.Pending) { + return localize('canvas.needsApproval', "Approval Required"); + } + if (canvas.trust.status === CanvasTrustStatus.Blocked) { + return localize('canvas.trustBlocked', "Blocked"); + } + switch (canvas.availability) { + case CanvasAvailabilityStatus.Ready: return localize('canvas.statusReady', "Ready"); + case CanvasAvailabilityStatus.Empty: return localize('canvas.statusEmpty', "Waiting for Content"); + case CanvasAvailabilityStatus.Loading: return localize('canvas.statusLoading', "Loading"); + case CanvasAvailabilityStatus.Failed: return localize('canvas.statusFailed', "Failed"); + case CanvasAvailabilityStatus.Unsupported: return localize('canvas.statusUnsupported', "Unsupported"); + case CanvasAvailabilityStatus.NotLoaded: return localize('canvas.statusNotLoaded', "Provider Not Loaded"); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isSessionCanvasReference(value: unknown): value is ISessionCanvasReference { + return isRecord(value) && typeof value.providerId === 'string' + && URI.isUri(value.session) && URI.isUri(value.chat) && URI.isUri(value.canvas); +} + +/** Invalid explicit owners must not fall through to editor-command active-editor defaults. */ +export function canvasReferenceFromContext(value: unknown): ISessionCanvasReference | undefined { + const invalidReference = localize('canvas.invalidReference', "Provide a valid logical canvas reference for this command."); + if (value instanceof SessionCanvasInput) { + return value.reference; + } + if (URI.isUri(value)) { + const reference = SessionCanvasUri.parse(value); + if (!reference) { + throw new Error(invalidReference); + } + return reference; + } + if (!isRecord(value) || !['providerId', 'session', 'chat', 'canvas'].some(key => hasKey(value, { [key]: true }))) { + return undefined; + } + if (!isSessionCanvasReference(value)) { + throw new Error(invalidReference); + } + try { + SessionCanvasUri.create(value); + } catch { + throw new Error(invalidReference); + } + return value; +} + +export class SessionCanvasActions { + constructor( + @ISessionCanvasService private readonly canvasService: ISessionCanvasService, + @ISessionContext private readonly sessionContext: ISessionContext, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IDialogService private readonly dialogService: IDialogService, + @IProgressService private readonly progressService: IProgressService, + ) { } + + resolveTarget(context?: unknown): ISessionCanvasTarget { + let session: URI | undefined; + let chat: URI | undefined; + if (context === undefined || context === null) { + const represented = this.sessionContext.session.get(); + session = represented?.resource; + chat = represented?.activeChat.get().resource; + } else if (isRecord(context) && URI.isUri(context.resource) && isObservable(context.activeChat)) { + session = context.resource; + chat = context.activeChat.get().resource; + } else if (isRecord(context) && typeof context.sessionResource === 'string' && typeof context.chatResource === 'string') { + session = URI.parse(context.sessionResource, true); + chat = URI.parse(context.chatResource, true); + } + const target = session && chat ? this.canvasService.getTarget(session, chat) : undefined; + if (!target) { + throw new Error(localize('canvas.selectOwner', "Select a supported local conversation with the canvas preview enabled.")); + } + return target; + } + + async manage(context?: unknown): Promise { + const target = this.resolveTarget(context); + while (true) { + const selection = await this.pick(target); + if (selection?.kind === 'initialize') { + const completed = await this.initializeProviders(target); + const represented = this.sessionContext.session.get(); + if (!completed || !represented || !isEqual(represented.resource, target.session.resource) + || !isEqual(represented.activeChat.get().resource, target.chat.resource)) { + return; + } + } else { + if (selection?.kind === 'instance') { + await this.canvasService.reveal(target, selection.canvas); + } else if (selection?.kind === 'type') { + await this.openType(target, selection.declaration); + } + return; + } + } + } + + private async pick(target: ISessionCanvasTarget): Promise { + const store = new DisposableStore(); + const picker = store.add(this.quickInputService.createQuickPick({ useSeparators: true })); + picker.title = localize('canvas.picker', "Canvases — {0}", target.chat.title.get()); + picker.matchOnDescription = true; + picker.matchOnDetail = true; + let selection: CanvasPick | undefined; + store.add(autorun(reader => { + const catalog = target.canvases.catalog.read(reader); + const entries = target.canvases.entries.read(reader); + const error = target.canvases.error.read(reader); + const available = this.canvasService.enabled.read(reader) && target.canvases.availability.read(reader) === 'available'; + const supportsInitialization = target.canvases.supportsInitialization.read(reader); + const initializing = target.canvases.initializing.read(reader); + picker.busy = target.canvases.loading.read(reader) || initializing; + picker.placeholder = !available + ? localize('canvas.pickerUnavailable', "The canvas runtime is unavailable. No provider is started by browsing.") + : initializing ? localize('canvas.pickerInitializing', "Canvas providers are being initialized for this conversation. Extension approval may be required.") + : error ? localize('canvas.pickerFailed', "The catalog could not be read. Refresh to retry this read only.") + : !catalog.length && !entries.length && !picker.busy + ? supportsInitialization + ? localize('canvas.pickerInitialize', "No live canvas types are registered. Initialize providers to discover them; extension approval may be required.") + : localize('canvas.pickerEmpty', "No live canvas types are registered for this chat. Refresh only reads existing providers.") + : localize('canvas.pickerHint', "Show an existing canvas or open a live type. Closing a tab only hides its view."); + const items: QuickPickInput[] = []; + if (available) { + if (entries.length) { + items.push({ type: 'separator', label: localize('canvas.instances', "Canvases in this chat") }); + items.push(...entries.map((canvas): CanvasPick => ({ + kind: 'instance', canvas, id: canvas.resource, label: canvas.title, + description: entryDescription(canvas), + }))); + } + if (catalog.length) { + items.push({ type: 'separator', label: localize('canvas.liveTypes', "Live canvas types") }); + items.push(...catalog.map((declaration): CanvasPick => ({ + kind: 'type', declaration, label: declaration.title, detail: declaration.description, + description: declaration.source.kind === CanvasSourceKind.Extension ? declaration.source.extensionId : declaration.source.packageName, + }))); + } + if (!catalog.length && supportsInitialization && !initializing) { + items.push({ + kind: 'initialize', label: localize('canvas.initializeProviders', "Initialize Canvas Providers"), + description: localize('canvas.initializeEffect', "May start extensions and request approval"), + }); + } + items.push({ kind: 'refresh', label: localize('canvas.refreshCatalog', "Refresh Live Catalog"), description: localize('canvas.refreshPure', "Does not start or restart providers") }); + } + picker.items = items; + })); + try { + const selected = new Promise(resolve => { + store.add(picker.onDidAccept(() => { + const item = picker.selectedItems[0]; + if (item?.kind === 'refresh') { + void target.canvases.refresh().catch(() => { /* The picker reads the collection's error state. */ }); + } else { + resolve(item); + } + })); + store.add(picker.onDidHide(() => resolve(undefined))); + }); + picker.show(); + void target.canvases.refresh().catch(() => { /* The picker reads the collection's error state. */ }); + selection = await selected; + } finally { + picker.hide(); + store.dispose(); + } + return selection; + } + + private async initializeProviders(target: ISessionCanvasTarget): Promise { + const store = new DisposableStore(); + const cancellation = store.add(new CancellationTokenSource()); + try { + await this.progressService.withProgress({ + location: ProgressLocation.Notification, + title: localize('canvas.initializingProviders', "Initializing Canvas Providers — {0}", target.chat.title.get()), + cancellable: true, + delay: 500, + }, () => target.canvases.initialize(cancellation.token), () => cancellation.cancel()); + return true; + } catch (error) { + if (isCancellationError(error)) { + return false; + } + throw error; + } finally { + store.dispose(); + } + } + + private async openType(target: ISessionCanvasTarget, declaration: CanvasTypeDeclaration): Promise { + const schema = declaration.openInputSchema ? JSON.stringify(declaration.openInputSchema) : declaration.openInputSchemaRef; + const value = await this.quickInputService.input({ + title: localize('canvas.openInputTitle', "Open {0}", declaration.title), + prompt: schema + ? localize('canvas.openInputSchema', "JSON input, validated by the provider. Declared schema: {0}", schema) + : localize('canvas.openInput', "Enter JSON input, or leave empty for no input."), + value: declaration.openInputSchema ? '{}' : '', + validateInput: async value => { + if (value.length > CANVAS_INPUT_MAX_LENGTH) { + return localize('canvas.inputTooLarge', "Canvas input exceeds the protocol size limit."); + } + try { + if (value.trim()) { + JSON.parse(value); + } + return undefined; + } catch { + return localize('canvas.inputInvalid', "Enter valid JSON."); + } + }, + }); + if (value === undefined) { + return; + } + const input: unknown = value.trim() ? JSON.parse(value) : undefined; + await this.canvasService.open(target, { + source: declaration.source, canvasType: declaration.canvasType, instanceId: generateUuid(), + title: declaration.title, icon: declaration.icon, input, + }); + } + + async restart(reference: ISessionCanvasReference): Promise { + const target = this.canvasService.getTarget(reference.session, reference.chat); + const entry = target?.canvases.entries.get().find(entry => entry.resource === reference.canvas.toString()); + if (!target || target.session.providerId !== reference.providerId || !entry) { + throw new Error(localize('canvas.restartMissing', "The canvas is no longer available in its owning chat.")); + } + const confirmation = await this.dialogService.confirm({ + type: 'warning', + message: localize('canvas.restartConfirm', "Restart the provider for {0}?", entry.title), + detail: localize('canvas.restartImpact', "This can replace every live canvas sharing this provider in the conversation \"{0}\". Process and page state may be lost; provider-owned files are retained. No actions are replayed. If recovery also requires replacing the owned runtime, a separate approval explains its impact on other resident chats.", target.chat.title.get()), + primaryButton: localize('canvas.restartButton', "Restart Provider"), + }); + if (confirmation.confirmed) { + await target.canvases.restart(entry); + } + } +} diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts new file mode 100644 index 0000000000000..ffc3a2cabb038 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasEditor.ts @@ -0,0 +1,308 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionCanvas.css'; +import { getZoomFactor, onDidChangeZoomLevel } from '../../../../base/browser/browser.js'; +import { $ } from '../../../../base/browser/dom.js'; +import { status } from '../../../../base/browser/ui/aria/aria.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { autorun, observableValue, transaction } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType, IAccessibleViewService } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { snapBrowserViewBounds } from '../../../../platform/browserView/common/browserView.js'; +import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { IEditorOpenContext } from '../../../../workbench/common/editor.js'; +import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; +import type { IBrowserViewModel } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import { BrowserViewPermissionHandler } from '../../../../workbench/contrib/browserView/electron-browser/browserViewPermissions.js'; +import { focusWebContentsViewContainer, WebContentsViewHost } from '../../../../workbench/contrib/browserView/electron-browser/webContentsViewHost.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISessionCanvasService, SessionCanvasInput } from '../common/sessionCanvas.js'; +import { SessionCanvasMount } from '../common/sessionCanvasMount.js'; +import type { SessionCanvasPresentationStatus } from '../common/sessionCanvasPresentation.js'; + +export const sessionCanvasFocused = new RawContextKey('sessionCanvasFocused', false); +export const sessionCanvasCanManage = new RawContextKey('sessionCanvasCanManage', false); + +export class SessionCanvasEditor extends EditorPane { + private wrapper!: HTMLElement; + private container!: HTMLElement; + private message!: HTMLElement; + private toolbar!: MenuWorkbenchToolBar; + private host!: WebContentsViewHost; + private model: IBrowserViewModel | undefined; + private readonly modelLifetime = this._register(new DisposableStore()); + private readonly currentInput = observableValue(this, undefined); + private readonly presentationVisible = observableValue(this, false); + private readonly nativeErrorCode = observableValue(this, undefined); + private readonly nativeLoading = observableValue(this, false); + private scopedContext: IContextKeyService | undefined; + private readonly semanticChanged = this._register(new Emitter()); + private semanticText = ''; + private semanticSequence = 0; + private helpAnnounced = false; + override get scopedContextKeyService(): IContextKeyService | undefined { return this.scopedContext; } + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ISessionCanvasService private readonly canvasService: ISessionCanvasService, + @IAccessibilityService private readonly accessibilityService: IAccessibilityService, + @IAccessibleViewService private readonly accessibleViewService: IAccessibleViewService, + @IHoverService private readonly hoverService: IHoverService, + @ILogService private readonly logService: ILogService, + ) { + super(SessionCanvasInput.EDITOR_ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + const context = this._register(this.contextKeyService.createScoped(parent)); + this.scopedContext = context; + sessionCanvasFocused.bindTo(context).set(true); + const canManage = sessionCanvasCanManage.bindTo(context); + const scoped = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, context]))); + const root = $('.browser-root.session-canvas-editor'); + const header = $('.session-canvas-header'); + const label = $('span'); + label.textContent = localize('canvas.header', "Canvas · tab close hides the view"); + this._register(this.hoverService.setupDelayedHover(label, { + content: localize('canvas.headerHover', "Closing the tab or hiding Editor releases this view. The canvas stays in its owning chat until you choose Close Canvas."), + })); + const actions = $('.session-canvas-actions'); + header.append(label, actions); + root.appendChild(header); + parent.appendChild(root); + this.toolbar = this._register(scoped.createInstance(MenuWorkbenchToolBar, actions, Menus.Canvas, { + menuOptions: { shouldForwardArgs: true }, + ariaLabel: localize('canvas.toolbar', "Canvas actions"), + })); + this.wrapper = $('.browser-container-wrapper'); + this.container = $('.browser-container'); + this.container.tabIndex = 0; + this.container.setAttribute('role', 'group'); + this.container.setAttribute('aria-label', localize('canvas.content', "Canvas content. Use Accessibility Help for keyboard navigation.")); + const placeholder = $('.browser-placeholder-contents'); + this.message = $('.session-canvas-message'); + this.message.setAttribute('role', 'status'); + placeholder.appendChild(this.message); + this.container.appendChild(placeholder); + this.wrapper.appendChild(this.container); + root.appendChild(this.wrapper); + this.host = this._register(scoped.createInstance(WebContentsViewHost, this.window, () => focusWebContentsViewContainer(this.container))); + placeholder.append(this.host.screenshotElement, this.host.pauseElement); + this.host.onContainerCreated(this.container); + this._register(onDidChangeZoomLevel(windowId => { + if (windowId === this.group.windowId) { + this.layout(); + } + })); + const mount = this._register(new SessionCanvasMount(this.canvasService, this.currentInput, this.presentationVisible, this.group.windowId)); + this._register(autorun(reader => { + const input = this.currentInput.read(reader); + const closing = input && this.canvasService.isClosing(input.reference, reader); + const enabled = this.canvasService.enabled.read(reader); + const target = input && this.canvasService.getTarget(input.reference.session, input.reference.chat, reader); + const availability = target?.canvases.availability.read(reader); + const member = target?.canvases.entries.read(reader).some(entry => entry.resource === input?.reference.canvas.toString()); + canManage.set(enabled && availability === 'available' && !!member && !closing); + const presentation = mount.presentation.read(reader); + this.setModel(presentation?.model.read(reader)); + const loading = this.nativeLoading.read(reader); + label.textContent = loading ? localize('canvas.headerLoading', "Canvas · loading page…") : localize('canvas.header', "Canvas · tab close hides the view"); + if (closing) { + this.message.textContent = localize('canvas.closing', "Closing this logical canvas…"); + return; + } + if (!presentation) { + this.message.textContent = !enabled ? localize('canvas.previewDisabled', "The canvas preview is disabled or AI features are hidden.") + : availability === 'disconnected' ? localize('canvas.disconnected', "The owning runtime is disconnected. Reconnect it before showing this canvas; no actions will be replayed.") + : availability === 'unsupported' ? presentationMessage('unsupported') + : !target ? localize('canvas.ownerUnavailable', "The owning conversation is unavailable or archived. Restoring this tab does not recreate it.") + : localize('canvas.viewUnavailable', "Show this view beside its owning conversation in the original Agents window. It cannot share an existing native attachment or move between windows."); + return; + } + const errorCode = this.nativeErrorCode.read(reader); + this.message.textContent = errorCode === undefined ? (loading ? localize('canvas.pageLoading', "Loading the native canvas page…") : presentationMessage(presentation.status.read(reader))) + : localize('canvas.pageFailed', "The native page could not load ({0}). Use Reload View to read a fresh source.", errorCode); + })); + } + + override async setInput(input: SessionCanvasInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { + this.currentInput.set(undefined, undefined); + await super.setInput(input, options, context, token); + if (!token.isCancellationRequested && this.input === input) { + this.toolbar.context = input.reference; + this.currentInput.set(input, undefined); + } + } + + private setModel(model: IBrowserViewModel | undefined): void { + if (this.model === model) { + return; + } + this.modelLifetime.clear(); + this.model = model; + this.invalidateSemanticContent(); + this.helpAnnounced = false; + transaction(tx => { + this.nativeErrorCode.set(model?.error?.errorCode, tx); + this.nativeLoading.set(model?.loading ?? false, tx); + }); + this.host.setModel(model); + this.container.removeAttribute('data-native-view-id'); + if (!model) { + return; + } + this.container.dataset.nativeViewId = model.id; + this.modelLifetime.add(this.instantiationService.createInstance(BrowserViewPermissionHandler, model)); + this.modelLifetime.add(model.onDidChangeFocus(event => { + if (event.focused) { + this._onDidFocus?.fire(); + focusWebContentsViewContainer(this.container); + if (!this.helpAnnounced && this.accessibilityService.isScreenReaderOptimized()) { + const hint = this.accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.SessionCanvas); + if (hint) { + status(hint); + } + this.helpAnnounced = true; + } + } + })); + this.modelLifetime.add(model.onDidChangeLoadingState(event => { + transaction(tx => { + this.nativeErrorCode.set(event.error?.errorCode, tx); + this.nativeLoading.set(event.loading, tx); + }); + })); + this.modelLifetime.add(model.onDidNavigate(() => this.invalidateSemanticContent())); + this.host.setVisible(this.presentationVisible.get()); + this.layout(); + } + + private invalidateSemanticContent(): void { + this.semanticSequence++; + this.semanticText = localize('canvas.semanticChanged', "The presented canvas changed. Close Accessible View and open it again for the current canvas."); + this.semanticChanged.fire(); + } + + override layout(): void { + if (!this.model) { + return; + } + const rect = this.wrapper.getBoundingClientRect(); + const zoomFactor = getZoomFactor(this.window); + const bounds = snapBrowserViewBounds({ x: rect.left, y: rect.top, width: rect.width, height: rect.height }, zoomFactor); + this.wrapper.style.setProperty('--zoom-factor', String(zoomFactor)); + this.container.style.left = `${bounds.x - rect.left}px`; + this.container.style.top = `${bounds.y - rect.top}px`; + this.container.style.width = `${bounds.width}px`; + this.container.style.height = `${bounds.height}px`; + void this.model.layout({ ...bounds, windowId: this.group.windowId, zoomFactor, cornerRadius: 0 }) + .catch(() => this.logService.warn('Canvas native layout could not be updated.')); + this.host.layout(); + } + + protected override setEditorVisible(visible: boolean): void { + this.presentationVisible.set(visible, undefined); + this.host?.setVisible(visible); + } + + override focus(): void { + if (!this.host.tryFocus()) { + this.container.focus(); + } + } + + override clearInput(): void { + this.currentInput.set(undefined, undefined); + super.clearInput(); + } + + override dispose(): void { + this.currentInput.set(undefined, undefined); + super.dispose(); + } + + createAccessibleProvider(type: AccessibleViewType): AccessibleContentProvider { + const input = this.input; + const help = [ + localize('canvas.help.overview', "This canvas belongs to the conversation identified by its editor. The provider owns its application and files; VS Code presents a private native page."), + localize('canvas.help.navigation', "Tab enters the page's controls. Use to leave the native page for another workbench part."), + localize('canvas.help.view', "Accessible View reads Chromium's main-frame accessible HTML names and control states for you only. It does not use an agent tool or share the page. Graphical content without HTML semantics has no inferred description."), + localize('canvas.help.close', "Closing the tab, hiding Editor, or switching conversations releases the native view without closing the logical canvas. Choose Close Canvas to remove it from its chat. Provider-owned files are not deleted."), + localize('canvas.help.recovery', "Reload View reads a fresh source without starting or restarting a provider. Restart Canvas Provider is an explicit operation and can affect every canvas sharing that provider in this chat. It does not replay actions. Broader owned-runtime recovery requires separate approval of its impact on resident chats."), + ].join('\n\n'); + this.semanticText = localize('canvas.reading', "Reading accessible HTML from the native page…"); + return new AccessibleContentProvider( + AccessibleViewProviderId.SessionCanvas, { type, language: 'plaintext' }, + () => type === AccessibleViewType.Help ? help : this.semanticText, + () => { + if (input instanceof SessionCanvasInput && this.input === input && !this._store.isDisposed + && !input.isDisposed() && this.presentationVisible.get() && this.canvasService.isVisibleOwner(input.reference)) { + this.focus(); + } + }, + AccessibilityVerbositySettingId.SessionCanvas, + type === AccessibleViewType.View ? () => { void this.refreshSemanticContent(); } : undefined, + undefined, undefined, undefined, this.semanticChanged.event, + ); + } + + private async refreshSemanticContent(): Promise { + const model = this.model; + const sequence = ++this.semanticSequence; + if (!model) { + this.semanticText = localize('canvas.noPage', "No native page is currently attached."); + } else { + try { + const snapshot = await model.getAccessibilitySnapshot(); + if (this.model !== model || sequence !== this.semanticSequence || this._store.isDisposed) { + return; + } + this.semanticText = localize('canvas.semanticScope', "Accessible HTML snapshot (main frame, user only).") + '\n\n' + + (snapshot.text || localize('canvas.noSemantics', "This page exposes no named accessible content. No description of graphical content can be inferred.")) + + (snapshot.truncated ? '\n\n' + localize('canvas.truncated', "The bounded snapshot is incomplete.") : ''); + } catch { + if (this.model !== model || sequence !== this.semanticSequence || this._store.isDisposed) { + return; + } + this.semanticText = localize('canvas.semanticError', "The native page's accessible content could not be read."); + } + } + this.semanticChanged.fire(); + } +} + +function presentationMessage(status: SessionCanvasPresentationStatus): string { + switch (status) { + case 'loading': return localize('canvas.loading', "Loading canvas state and reading its current source…"); + case 'attached': return ''; + case 'empty': return localize('canvas.empty', "The provider is live but has not produced content yet."); + case 'pendingTrust': return localize('canvas.pendingTrust', "Waiting for source execution approval. Refreshing this view does not start the provider."); + case 'blocked': return localize('canvas.blocked', "The canvas provider is blocked. Review source approval and runtime policy before trying again."); + case 'unsupported': return localize('canvas.unsupported', "This runtime and client do not currently support canvases. The local desktop preview and a compatible runtime must both be available."); + case 'unavailable': return localize('canvas.unavailable', "The canvas has no current source. Restart Canvas Provider requests explicit recovery without replaying actions. Broader runtime recovery requires separate approval."); + case 'failed': return localize('canvas.failed', "Canvas state, source resolution, or native presentation failed. Reload View rereads the source. Restart Canvas Provider is a separate, explicit recovery operation."); + case 'closed': return localize('canvas.closed', "This canvas is no longer in its owning chat. Close this tab or open a canvas from the live catalog."); + } +} diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts new file mode 100644 index 0000000000000..e7ca8ce493368 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvasService.ts @@ -0,0 +1,256 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../base/browser/window.js'; +import { Sequencer } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore, type IReference } from '../../../../base/common/lifecycle.js'; +import { autorun, observableFromEvent, observableValue, type IReader } from '../../../../base/common/observable.js'; +import { isEqual } 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 { ILogService } from '../../../../platform/log/common/log.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import { IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { SessionCanvasesEnabledSettingId, SessionCanvasUri, type CanvasEntry, type ISessionCanvasReference, type SessionCanvasOpenOptions } from '../../../services/sessions/common/sessionCanvases.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionCanvasService, SessionCanvasInput, type ISessionCanvasTarget } from '../common/sessionCanvas.js'; +import { SessionCanvasPresentation } from '../common/sessionCanvasPresentation.js'; + +export class SessionCanvasService extends Disposable implements ISessionCanvasService { + declare readonly _serviceBrand: undefined; + readonly enabled; + private readonly inputs = this._register(new DisposableMap()); + private readonly inputLifetimes = this._register(new DisposableMap()); + private readonly presentations = this._register(new DisposableMap()); + private readonly closing = observableValue>(this, new Set()); + private readonly nativeCreations = new Map(); + + constructor( + @ISessionsManagementService private readonly managementService: ISessionsManagementService, + @ISessionsService private readonly sessionsService: ISessionsService, + @IBrowserViewWorkbenchService private readonly browserService: IBrowserViewWorkbenchService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IChatEntitlementService entitlementService: IChatEntitlementService, + @INotificationService private readonly notificationService: INotificationService, + @ILogService private readonly logService: ILogService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + this.enabled = observableFromEvent(this, Event.any(entitlementService.onDidChangeSentiment, configurationService.onDidChangeConfiguration), + () => !entitlementService.sentiment.hidden && configurationService.getValue(SessionCanvasesEnabledSettingId) === true); + let previousTarget: string | undefined; + let previousEntries = new Set(); + let initialized = false; + this._register(autorun(reader => { + if (!this.enabled.read(reader)) { + this.presentations.clearAndDisposeAll(); + this.inputs.clearAndDisposeAll(); + previousTarget = undefined; + return; + } + const session = sessionsService.activeSession.read(reader); + const chat = session?.activeChat.read(reader); + const supported = session?.capabilities.read(reader).supportsCanvases; + const target = session && chat && supported ? this.getTarget(session.resource, chat.resource, reader) : undefined; + const key = target ? `${target.session.sessionId}/${target.chat.resource.toString()}` : undefined; + const entries = target?.canvases.entries.read(reader) ?? []; + const nextInitialized = target?.canvases.initialized.read(reader) ?? false; + const generation = target?.canvases.generation.read(reader); + const targetKey = key === undefined ? undefined : `${key}/${generation}`; + if (targetKey === previousTarget && initialized && nextInitialized && target) { + for (const entry of entries) { + if (!previousEntries.has(entry.resource)) { + void this.reveal(target, entry, true).catch(() => this.logService.warn('Canvas presentation could not be revealed.')); + } + } + } + previousTarget = targetKey; + previousEntries = new Set(entries.map(entry => entry.resource)); + initialized = nextInitialized; + if (target) { + for (const entry of entries) { + this.inputs.get(SessionCanvasUri.create(this.reference(target, entry)).toString())?.setTitle(entry.title); + } + } + })); + } + + getTarget(sessionResource: URI, chatResource: URI, reader?: IReader): ISessionCanvasTarget | undefined { + const session = this.managementService.getSession(sessionResource, { includeDrafts: true }); + const chat = session?.chats.read(reader).find(chat => isEqual(chat.resource, chatResource)); + if (!this.enabled.read(reader) || !session || !chat || session.isArchived.read(reader)) { + return undefined; + } + const canvases = this.managementService.getSessionCanvases(sessionResource, chatResource); + return canvases ? { session, chat, canvases } : undefined; + } + + isVisibleOwner(reference: ISessionCanvasReference, reader?: IReader): boolean { + const session = this.sessionsService.activeSession.read(reader); + return this.enabled.read(reader) && !!session && session.providerId === reference.providerId + && session.capabilities.read(reader).supportsCanvases === true + && !session.isArchived.read(reader) && isEqual(session.resource, reference.session) + && session.chats.read(reader).some(chat => isEqual(chat.resource, reference.chat)) + && isEqual(session.activeChat.read(reader).resource, reference.chat); + } + + isClosing(reference: ISessionCanvasReference, reader?: IReader): boolean { + return this.closing.read(reader).has(SessionCanvasUri.create(reference).toString()); + } + + getInput(resource: URI): SessionCanvasInput { + const key = resource.toString(); + let input = this.inputs.get(key); + if (!input || input.isDisposed()) { + input = new SessionCanvasInput(resource); + this.inputs.set(key, input); + const lifetime = new DisposableStore(); + this.inputLifetimes.set(key, lifetime); + lifetime.add(Event.once(input.onWillDispose)(() => { + this.presentations.deleteAndDispose(key); + this.inputs.deleteAndLeak(key); + this.inputLifetimes.deleteAndDispose(key); + })); + } + const { reference } = input; + const target = this.getTarget(reference.session, reference.chat); + if (target?.session.providerId === reference.providerId) { + const entry = target.canvases.entries.get().find(entry => entry.resource === reference.canvas.toString()); + if (entry) { + input.setTitle(entry.title); + } + } + return input; + } + + acquirePresentation(input: SessionCanvasInput, windowId: number): IReference | undefined { + const { reference } = input; + if (input.isDisposed() || windowId !== mainWindow.vscodeWindowId || !this.isVisibleOwner(reference) || this.isClosing(reference)) { + return undefined; + } + const target = this.getTarget(reference.session, reference.chat); + if (!target || target.session.providerId !== reference.providerId) { + return undefined; + } + const key = input.resource.toString(); + if (this.presentations.has(key)) { + return undefined; + } + let released = false; + const presentation = new SessionCanvasPresentation(target.canvases, reference.canvas.toString(), async url => { + let creations = this.nativeCreations.get(key); + if (!creations) { + creations = { sequencer: new Sequencer(), pending: 0 }; + this.nativeCreations.set(key, creations); + } + creations.pending++; + try { + return await creations.sequencer.queue(() => { + if (released || input.isDisposed() || this._store.isDisposed) { + throw new Error('Canvas presentation was detached.'); + } + return this.browserService.getOrCreateExternalBrowserView(generateUuid(), input.resource, url); + }); + } finally { + if (--creations.pending === 0) { + this.nativeCreations.delete(key); + } + } + }); + this.presentations.set(key, presentation); + return { + object: presentation, + dispose: () => { + released = true; + if (this.presentations.get(key) === presentation) { + this.presentations.deleteAndDispose(key); + } + }, + }; + } + + private reference(target: ISessionCanvasTarget, canvas: CanvasEntry): ISessionCanvasReference { + return { providerId: target.session.providerId, session: target.session.resource, chat: target.chat.resource, canvas: URI.parse(canvas.resource) }; + } + + async open(target: ISessionCanvasTarget, options: SessionCanvasOpenOptions): Promise { + this.assertTarget(target); + const canvas = await target.canvases.open(options); + if (!this.isVisibleOwner(this.reference(target, canvas))) { + this.notificationService.info(localize('canvas.openedElsewhere', "The canvas belongs to its original conversation. Select that conversation and use Canvases to show it.")); + return SessionCanvasUri.create(this.reference(target, canvas)); + } + return this.reveal(target, canvas); + } + + async reveal(target: ISessionCanvasTarget, canvas: CanvasEntry, preserveFocus = false): Promise { + this.assertTarget(target); + const reference = this.reference(target, canvas); + const resource = SessionCanvasUri.create(reference); + if (!this.isVisibleOwner(reference)) { + return resource; + } + const input = this.getInput(resource); + input.setTitle(canvas.title); + const pane = await this.editorService.openEditor(input, { pinned: true, revealIfOpened: true, preserveFocus }, this.editorGroupsService.mainPart.activeGroup); + if (pane?.input === input && !this.isVisibleOwner(reference)) { + await this.editorService.closeEditor({ editor: input, groupId: pane.group.id }, { preserveFocus: true }); + } + return resource; + } + + private assertTarget(target: ISessionCanvasTarget): void { + const current = this.getTarget(target.session.resource, target.chat.resource); + if (!current || current.session.providerId !== target.session.providerId || current.canvases !== target.canvases) { + throw new Error(localize('canvas.targetUnavailable', "The owning chat is no longer available for canvas operations.")); + } + } + + private currentEntry(reference: ISessionCanvasReference): { target: ISessionCanvasTarget; canvas: CanvasEntry } { + const target = this.getTarget(reference.session, reference.chat); + const canvas = target?.canvases.entries.get().find(entry => entry.resource === reference.canvas.toString()); + if (!target || target.session.providerId !== reference.providerId || !canvas) { + throw new Error(localize('canvas.noLongerAvailable', "The canvas is no longer available in its owning chat.")); + } + return { target, canvas }; + } + + async close(reference: ISessionCanvasReference): Promise { + const { target, canvas } = this.currentEntry(reference); + const resource = SessionCanvasUri.create(reference); + const key = resource.toString(); + if (this.closing.get().has(key)) { + return; + } + this.closing.set(new Set([...this.closing.get(), key]), undefined); + this.presentations.deleteAndDispose(key); + try { + await target.canvases.close(canvas); + this.inputs.deleteAndDispose(key); + } finally { + const closing = new Set(this.closing.get()); + closing.delete(key); + this.closing.set(closing, undefined); + } + } + + async restart(reference: ISessionCanvasReference): Promise { + const { target, canvas } = this.currentEntry(reference); + await target.canvases.restart(canvas); + } + + reload(reference: ISessionCanvasReference): void { + this.currentEntry(reference); + this.presentations.get(SessionCanvasUri.create(reference).toString())?.reload(); + } +} diff --git a/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts new file mode 100644 index 0000000000000..0db34bf8637f5 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/electron-browser/sessionCanvases.contribution.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { AccessibleViewType, IAccessibleViewService } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IListService } from '../../../../platform/list/browser/listService.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../workbench/browser/editor.js'; +import { resolveCommandsContext } from '../../../../workbench/browser/parts/editor/editorCommandsContext.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../workbench/common/contextkeys.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../workbench/common/editor.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IEditorResolverService, RegisteredEditorPriority } from '../../../../workbench/services/editor/common/editorResolverService.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionSupportsCanvasesContext } from '../../../common/contextkeys.js'; +import { SessionsCategories } from '../../../common/categories.js'; +import { SessionCanvasesEnabledSettingId, SessionCanvasUri, type ISessionCanvasReference } from '../../../services/sessions/common/sessionCanvases.js'; +import { ISessionCanvasService, SessionCanvasInput, SessionCanvasSerializer } from '../common/sessionCanvas.js'; +import { canvasReferenceFromContext, SessionCanvasActions, SessionCanvasCommands } from './sessionCanvasActions.js'; +import { SessionCanvasEditor, sessionCanvasCanManage, sessionCanvasFocused } from './sessionCanvasEditor.js'; +import { SessionCanvasService } from './sessionCanvasService.js'; + +const enabled = ContextKeyExpr.and(IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated(), ChatContextKeys.enabled, ContextKeyExpr.equals(`config.${SessionCanvasesEnabledSettingId}`, true)); +const active = ContextKeyExpr.and(enabled, ContextKeyExpr.equals('activeEditor', SessionCanvasInput.EDITOR_ID)); +const canManage = ContextKeyExpr.and(active, sessionCanvasCanManage); +Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', title: localize('canvas.configurationTitle', "Agents"), + properties: { + [SessionCanvasesEnabledSettingId]: { + type: 'boolean', default: false, scope: ConfigurationScope.WINDOW, tags: ['experimental'], + markdownDescription: localize('canvas.configurationDescription', "Show native canvas views in the local Agents window when the connected runtime supports them. This preview preference does not approve extension execution, grant environment access, or override runtime policy. Web, remote execution, and moving views between windows are not supported."), + }, + }, +}); +registerSingleton(ISessionCanvasService, SessionCanvasService, InstantiationType.Delayed); +Registry.as(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create(SessionCanvasEditor, SessionCanvasInput.EDITOR_ID, localize('canvas.editor', "Canvas")), + [new SyncDescriptor(SessionCanvasInput)], +); +Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(SessionCanvasInput.ID, SessionCanvasSerializer); + +class SessionCanvasesContribution extends Disposable { + static readonly ID = 'sessions.contrib.canvases'; + constructor( + @IEditorResolverService resolverService: IEditorResolverService, + @ISessionCanvasService canvasService: ISessionCanvasService, + ) { + super(); + this._register(resolverService.registerEditor(`${SessionCanvasUri.scheme}:/**`, { + id: SessionCanvasInput.EDITOR_ID, label: localize('canvas.editor', "Canvas"), priority: RegisteredEditorPriority.exclusive, + }, { singlePerResource: true }, { + createEditorInput: ({ resource, options }) => ({ editor: canvasService.getInput(resource), options }), + })); + for (const type of [AccessibleViewType.Help, AccessibleViewType.View]) { + this._register(AccessibleViewRegistry.register({ + type, priority: 200, name: `sessionCanvas-${type}`, when: ContextKeyExpr.and(active, sessionCanvasFocused), + getProvider: accessor => { + const pane = accessor.get(IEditorService).activeEditorPane; + return pane instanceof SessionCanvasEditor ? pane.createAccessibleProvider(type) : undefined; + }, + })); + } + } +} +registerWorkbenchContribution2(SessionCanvasesContribution.ID, SessionCanvasesContribution, WorkbenchPhase.BlockRestore); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: SessionCanvasCommands.manage, title: localize2('canvas.manage', "Canvases…"), category: SessionsCategories.Sessions, icon: Codicon.preview, f1: true, + precondition: ContextKeyExpr.and(enabled, SessionSupportsCanvasesContext), + menu: [ + { id: Menus.SessionBarToolbar, group: 'navigation', order: 30, when: ContextKeyExpr.and(enabled, SessionSupportsCanvasesContext) }, + { id: Menus.SessionsEditorTabsBarAddTab, group: 'navigation', order: 4, when: ContextKeyExpr.and(enabled, IsTopRightEditorGroupContext, SessionSupportsCanvasesContext) }, + ], + }); + } + async run(accessor: ServicesAccessor, context?: unknown): Promise { + await accessor.get(IInstantiationService).createInstance(SessionCanvasActions).manage(context); + } +}); + +function references(accessor: ServicesAccessor, args: unknown[]): ISessionCanvasReference[] { + const explicit = canvasReferenceFromContext(args[0]); + if (explicit) { + return [explicit]; + } + const context = resolveCommandsContext(args, accessor.get(IEditorService), accessor.get(IEditorGroupsService), accessor.get(IListService)); + return context.groupedEditors.flatMap(group => group.editors.filter((editor): editor is SessionCanvasInput => editor instanceof SessionCanvasInput).map(editor => editor.reference)); +} + +registerAction2(class extends Action2 { + constructor() { + super({ id: SessionCanvasCommands.reload, title: localize2('canvas.reload', "Reload View"), category: SessionsCategories.Sessions, icon: Codicon.refresh, f1: true, precondition: canManage, menu: [{ id: Menus.Canvas, group: 'navigation', order: 1, when: enabled }] }); + } + run(accessor: ServicesAccessor, ...args: unknown[]): void { + for (const reference of references(accessor, args)) { + accessor.get(ISessionCanvasService).reload(reference); + } + } +}); +registerAction2(class extends Action2 { + constructor() { + super({ id: SessionCanvasCommands.close, title: localize2('canvas.close', "Close Canvas"), category: SessionsCategories.Sessions, icon: Codicon.close, f1: true, precondition: canManage, menu: [{ id: Menus.Canvas, group: 'manage', order: 3, when: enabled }] }); + } + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const service = accessor.get(ISessionCanvasService); + for (const reference of references(accessor, args)) { + try { + await service.close(reference); + } catch (error) { + throw new Error(localize('canvas.closeUncertain', "Close Canvas did not complete. Its outcome may be uncertain; check membership in Canvases before retrying."), { cause: error }); + } + } + } +}); +registerAction2(class extends Action2 { + constructor() { + super({ id: SessionCanvasCommands.restart, title: localize2('canvas.restart', "Restart Canvas Provider…"), category: SessionsCategories.Sessions, f1: true, precondition: canManage, menu: [{ id: Menus.Canvas, group: 'manage', order: 2, when: enabled }] }); + } + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise { + const actions = accessor.get(IInstantiationService).createInstance(SessionCanvasActions); + for (const reference of references(accessor, args)) { + try { + await actions.restart(reference); + } catch (error) { + throw new Error(localize('canvas.restartUnavailable', "The canvas provider could not be restarted. Recovery may be unsupported or the outcome uncertain. Check the owning runtime before trying again; the operation was not automatically retried."), { cause: error }); + } + } + } +}); +registerAction2(class extends Action2 { + constructor() { + super({ id: SessionCanvasCommands.accessibleView, title: localize2('canvas.readAccessible', "Read Accessible Content"), icon: Codicon.book, precondition: active, menu: [{ id: Menus.Canvas, group: 'navigation', order: 2, when: enabled }] }); + } + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const editorService = accessor.get(IEditorService); + for (const reference of references(accessor, args)) { + const resource = SessionCanvasUri.create(reference); + const pane = editorService.visibleEditorPanes.find(pane => pane.input instanceof SessionCanvasInput && isEqual(pane.input.resource, resource)); + if (pane instanceof SessionCanvasEditor) { + accessor.get(IAccessibleViewService).show(pane.createAccessibleProvider(AccessibleViewType.View)); + return; + } + } + } +}); diff --git a/src/vs/sessions/contrib/canvases/test/common/sessionCanvasPresentation.test.ts b/src/vs/sessions/contrib/canvases/test/common/sessionCanvasPresentation.test.ts new file mode 100644 index 0000000000000..f7787b7d4a9a9 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/test/common/sessionCanvasPresentation.test.ts @@ -0,0 +1,379 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import type { IBrowserViewModel } from '../../../../../workbench/contrib/browserView/common/browserView.js'; +import { canvasIdentityEquals, CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, SessionCanvasUri, type CanvasSource, type CanvasState, type ISessionCanvasReference } from '../../../../services/sessions/common/sessionCanvases.js'; +import { ISessionCanvasService, SessionCanvasInput, SessionCanvasSerializer } from '../../common/sessionCanvas.js'; +import { SessionCanvasPresentation, validateCanvasPresentationUrl } from '../../common/sessionCanvasPresentation.js'; +import { canvasEntry, createCanvasState, TestSessionCanvases } from './sessionCanvasTestUtils.js'; + +suite('Session canvas presentation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function model(id: string) { + let disposed = false; + const willDispose = store.add(new Emitter()); + const value = new class extends mock() { + override readonly id = id; + override readonly onWillDispose = willDispose.event; + override dispose(): void { + if (!disposed) { + disposed = true; + willDispose.fire(); + } + } + }(); + return { value, disposed: () => disposed }; + } + + function fixture() { + const canvases = store.add(new TestSessionCanvases()); + const models: ReturnType[] = []; + const urls: string[] = []; + const presentation = store.add(new SessionCanvasPresentation(canvases, canvases.entries.get()[0].resource, async url => { + urls.push(url); + const next = model(String(models.length)); + models.push(next); + return next.value; + })); + return { canvases, models, urls, presentation }; + } + + test('a late same-state credential renewal cannot replace a newer attachment', async () => { + const { canvases, presentation, urls, models } = fixture(); + presentation.reload(); + await canvases.completeSource(1, 'http://127.0.0.1:43123/new?credential=new'); + await timeout(0); + await canvases.completeSource(0, 'http://127.0.0.1:43123/old?credential=old'); + await timeout(0); + assert.deepStrictEqual({ + urls, status: presentation.status.get(), model: presentation.model.get()?.id, disposed: models.map(model => model.disposed()), effects: canvases.effects, + }, { urls: ['http://127.0.0.1:43123/new?credential=new'], status: 'attached', model: '0', disposed: [false], effects: [] }); + }); + + test('incarnation and revision changes fence old pulls and retire the previous native model', async () => { + const { canvases, presentation, models, urls } = fixture(); + await canvases.completeSource(0); + await timeout(0); + const before = createCanvasState(); + canvases.setState({ ...before, revision: 2 }); + canvases.setState({ ...before, revision: 3, identity: { ...before.identity, incarnation: 'incarnation-2' } }); + await canvases.completeSource(2, 'https://fixture.invalid/latest'); + await canvases.completeSource(1, 'https://fixture.invalid/stale'); + await timeout(0); + assert.deepStrictEqual({ urls, disposed: models.map(model => model.disposed()), status: presentation.status.get() }, + { urls: ['http://127.0.0.1:43123/canvas', 'https://fixture.invalid/latest'], disposed: [true, false], status: 'attached' }); + }); + + test('metadata revisions preserve the live page through entry and full-state catch-up', async () => { + const { canvases, presentation, models, urls } = fixture(); + await canvases.completeSource(0); + await timeout(0); + const updated = { ...createCanvasState(), title: 'Updated counter', revision: 2 }; + canvases.entries.set([canvasEntry(updated)], undefined); + const duringCatchUp = { model: presentation.model.get()?.id, disposed: models[0].disposed(), pulls: canvases.sourceRequests.length }; + canvases.state.set(updated, undefined); + const duringPull = { model: presentation.model.get()?.id, disposed: models[0].disposed(), pulls: canvases.sourceRequests.length }; + await canvases.completeSource(1); + await timeout(0); + assert.deepStrictEqual({ duringCatchUp, duringPull, urls, model: presentation.model.get()?.id, status: presentation.status.get() }, { + duringCatchUp: { model: '0', disposed: false, pulls: 1 }, + duringPull: { model: '0', disposed: false, pulls: 2 }, + urls: ['http://127.0.0.1:43123/canvas'], model: '0', status: 'attached', + }); + }); + + test('adding, changing and removing an icon does not remount the page or replay an effect', async () => { + const { canvases, presentation, models, urls } = fixture(); + await canvases.completeSource(0); + await timeout(0); + const original = createCanvasState(); + const states: CanvasState[] = [ + { ...original, revision: 2, icon: { src: 'https://fixture.invalid/first.png' } }, + { ...original, revision: 3, icon: { src: 'https://fixture.invalid/second.png' } }, + { ...original, revision: 4 }, + ]; + for (const state of states) { + canvases.setState(state); + await canvases.completeSource(canvases.sourceRequests.length - 1); + await timeout(0); + } + assert.deepStrictEqual({ + urls, model: presentation.model.get()?.id, status: presentation.status.get(), + disposed: models.map(model => model.disposed()), effects: canvases.effects, pulls: canvases.sourceRequests.length, + }, { + urls: ['http://127.0.0.1:43123/canvas'], model: '0', status: 'attached', + disposed: [false], effects: [], pulls: 4, + }); + }); + + test('explicit reload replaces the page even when the resolved source is unchanged', async () => { + const { canvases, presentation, models, urls } = fixture(); + await canvases.completeSource(0); + await timeout(0); + presentation.reload(); + await canvases.completeSource(1); + await timeout(0); + assert.deepStrictEqual({ urls, disposed: models.map(model => model.disposed()), model: presentation.model.get()?.id, effects: canvases.effects }, { + urls: ['http://127.0.0.1:43123/canvas', 'http://127.0.0.1:43123/canvas'], disposed: [true, false], model: '1', effects: [], + }); + }); + + test('explicit reload during metadata catch-up still replaces the page once state agrees', async () => { + const { canvases, presentation, models } = fixture(); + await canvases.completeSource(0); + await timeout(0); + const updated = { ...createCanvasState(), revision: 2 }; + canvases.entries.set([canvasEntry(updated)], undefined); + presentation.reload(); + canvases.state.set(updated, undefined); + await canvases.completeSource(1); + await timeout(0); + assert.deepStrictEqual({ disposed: models.map(model => model.disposed()), model: presentation.model.get()?.id, effects: canvases.effects }, + { disposed: [true, false], model: '1', effects: [] }); + }); + + test('a changed source at the same incarnation replaces the live page', async () => { + const { canvases, presentation, models, urls } = fixture(); + await canvases.completeSource(0); + await timeout(0); + canvases.setState({ ...createCanvasState(), revision: 2 }); + await canvases.completeSource(1, 'http://127.0.0.1:43123/replacement'); + await timeout(0); + assert.deepStrictEqual({ urls, disposed: models.map(model => model.disposed()), model: presentation.model.get()?.id }, { + urls: ['http://127.0.0.1:43123/canvas', 'http://127.0.0.1:43123/replacement'], disposed: [true, false], model: '1', + }); + }); + + test('a failed metadata source refresh retires the retained page', async () => { + const { canvases, presentation, models } = fixture(); + await canvases.completeSource(0); + await timeout(0); + canvases.setState({ ...createCanvasState(), revision: 2 }); + await canvases.sourceRequests[1].result.error(new Error('Source resolution failed')); + await timeout(0); + assert.deepStrictEqual({ disposed: models.map(model => model.disposed()), model: presentation.model.get(), status: presentation.status.get() }, + { disposed: [true], model: undefined, status: 'failed' }); + }); + + test('a newer full-state trust withdrawal retires the page before its entry catches up', async () => { + const { canvases, presentation, models } = fixture(); + await canvases.completeSource(0); + await timeout(0); + canvases.state.set({ ...createCanvasState(), revision: 2, trust: { status: CanvasTrustStatus.Blocked } }, undefined); + assert.deepStrictEqual({ disposed: models.map(model => model.disposed()), model: presentation.model.get(), pulls: canvases.sourceRequests.length }, + { disposed: [true], model: undefined, pulls: 1 }); + }); + + test('disposal during a source pull never creates a native view or sends logical close', async () => { + const { canvases, presentation, models } = fixture(); + presentation.dispose(); + await canvases.completeSource(0); + await timeout(0); + assert.deepStrictEqual({ models: models.length, subscriptions: canvases.subscriptions, effects: canvases.effects }, { models: 0, subscriptions: 0, effects: [] }); + }); + + test('a native creation that completes after detach is disposed without attachment', async () => { + const canvases = store.add(new TestSessionCanvases()); + const pending = new DeferredPromise(); + const created = model('pending'); + const presentation = store.add(new SessionCanvasPresentation(canvases, createCanvasState().resource, () => pending.p)); + await canvases.completeSource(0); + await timeout(0); + presentation.dispose(); + await pending.complete(created.value); + await timeout(0); + assert.deepStrictEqual({ disposed: created.disposed(), model: presentation.model.get(), effects: canvases.effects }, { disposed: true, model: undefined, effects: [] }); + }); + + test('source renewal queues native creation rather than accumulating concurrent native requests', async () => { + const canvases = store.add(new TestSessionCanvases()); + const creations: DeferredPromise[] = []; + const presentation = store.add(new SessionCanvasPresentation(canvases, createCanvasState().resource, async () => { + const pending = new DeferredPromise(); + creations.push(pending); + return pending.p; + })); + await canvases.completeSource(0); + await timeout(0); + presentation.reload(); + await canvases.completeSource(1); + await timeout(0); + const whilePending = creations.length; + const first = model('first'); + const second = model('second'); + await creations[0].complete(first.value); + await timeout(0); + await creations[1].complete(second.value); + await timeout(0); + assert.deepStrictEqual({ whilePending, finalCreations: creations.length, firstDisposed: first.disposed(), model: presentation.model.get()?.id }, + { whilePending: 1, finalCreations: 2, firstDisposed: true, model: 'second' }); + }); + + test('entry and full state must agree before source resolution', async () => { + const canvases = store.add(new TestSessionCanvases()); + const original = createCanvasState(); + canvases.entries.set([canvasEntry({ ...original, revision: 2 })], undefined); + const presentation = store.add(new SessionCanvasPresentation(canvases, original.resource, async () => model('matching').value)); + const before = { pulls: canvases.sourceRequests.length, status: presentation.status.get() }; + canvases.state.set({ ...original, revision: 2 }, undefined); + await canvases.completeSource(0); + await timeout(0); + assert.deepStrictEqual({ before, pulls: canvases.sourceRequests.length, status: presentation.status.get() }, + { before: { pulls: 0, status: 'loading' }, pulls: 1, status: 'attached' }); + }); + + test('same-revision full state cannot substitute another source, type, instance, or availability', () => { + const original = createCanvasState(); + const mismatches: CanvasState[] = [ + { ...original, identity: { ...original.identity, canvasType: 'another-type' } }, + { ...original, identity: { ...original.identity, instanceId: 'another-instance' } }, + { ...original, identity: { ...original.identity, source: { kind: CanvasSourceKind.Extension, extensionId: 'another-extension' } } }, + { ...original, availability: { status: CanvasAvailabilityStatus.NotLoaded } }, + ]; + const results = mismatches.map(state => { + const canvases = store.add(new TestSessionCanvases()); + canvases.state.set(state, undefined); + const presentation = store.add(new SessionCanvasPresentation(canvases, original.resource, async () => model('unused').value)); + return { status: presentation.status.get(), pulls: canvases.sourceRequests.length }; + }); + assert.deepStrictEqual(results, mismatches.map(() => ({ status: 'loading', pulls: 0 }))); + }); + + test('source-less empty and unsupported states remain distinct from provider loss', async () => { + const results: string[] = []; + for (const availability of [CanvasAvailabilityStatus.Empty, CanvasAvailabilityStatus.Unsupported, CanvasAvailabilityStatus.NotLoaded]) { + const f = fixture(); + await f.canvases.completeSource(0, undefined, { availability, source: undefined }); + await timeout(0); + results.push(`${f.presentation.status.get()}:${f.models.length}`); + } + assert.deepStrictEqual(results, ['empty:0', 'unsupported:0', 'unavailable:0']); + }); + + test('logical identity ignores package names and versions but not the registered source identifier', () => { + const source: CanvasSource = { kind: CanvasSourceKind.Package, sourceId: 'registered-source', packageName: 'counter', version: '1' }; + const original = { ...createCanvasState().identity, source }; + assert.deepStrictEqual([ + canvasIdentityEquals(original, { ...original, source: { ...original.source, packageName: 'renamed-counter', version: '2' } }), + canvasIdentityEquals(original, { ...original, source: { ...original.source, sourceId: 'another-source' } }), + ], [true, false]); + }); + + test('disconnection and reconnect generations reject stale sources', async () => { + const { canvases, presentation, urls } = fixture(); + canvases.availability.set('disconnected', undefined); + const disconnected = presentation.status.get(); + canvases.generation.set(2, undefined); + canvases.availability.set('available', undefined); + await canvases.completeSource(0, 'https://fixture.invalid/old-connection'); + await canvases.completeSource(1, 'https://fixture.invalid/current-connection'); + await timeout(0); + assert.deepStrictEqual({ disconnected, urls, status: presentation.status.get() }, + { disconnected: 'unavailable', urls: ['https://fixture.invalid/current-connection'], status: 'attached' }); + }); + + test('pending approval, blocked, unavailable and removed members do not create native pages', async () => { + const { canvases, presentation, models } = fixture(); + const statuses: string[] = []; + const original = createCanvasState(); + canvases.setState({ ...original, trust: { status: CanvasTrustStatus.Pending } }); + statuses.push(presentation.status.get()); + canvases.setState({ ...original, trust: { status: CanvasTrustStatus.Blocked } }); + statuses.push(presentation.status.get()); + canvases.setState({ ...original, availability: { status: CanvasAvailabilityStatus.NotLoaded } }); + statuses.push(presentation.status.get()); + canvases.entries.set([], undefined); + statuses.push(presentation.status.get()); + await canvases.completeSource(0); + await timeout(0); + assert.deepStrictEqual({ statuses, models: models.length, effects: canvases.effects }, + { statuses: ['pendingTrust', 'blocked', 'unavailable', 'closed'], models: 0, effects: [] }); + }); + + test('manual reload retries a failed state subscription without executing the provider', () => { + const canvases = store.add(new TestSessionCanvases()); + canvases.stateError.set(new Error('Controlled subscription failure'), undefined); + const presentation = store.add(new SessionCanvasPresentation(canvases, createCanvasState().resource, async () => model('unused').value)); + presentation.reload(); + assert.deepStrictEqual({ status: presentation.status.get(), subscriptions: canvases.subscriptions, total: canvases.totalSubscriptions, pulls: canvases.sourceRequests.length, effects: canvases.effects }, + { status: 'failed', subscriptions: 1, total: 2, pulls: 0, effects: [] }); + }); + + test('mismatched source identity and expired or malformed expiry hints never mount', async () => { + const values = [ + { incarnation: 'different' }, + { revision: 999 }, + { source: { url: 'https://fixture.invalid/view', expiresAt: '1970-01-01T00:00:00Z' } }, + { source: { url: 'https://fixture.invalid/view', expiresAt: 'invalid-date' } }, + ]; + const statuses: string[] = []; + for (const value of values) { + const { canvases, presentation, models } = fixture(); + await canvases.completeSource(0, undefined, value); + await timeout(0); + statuses.push(`${presentation.status.get()}:${models.length}`); + } + assert.deepStrictEqual(statuses, ['unavailable:0', 'unavailable:0', 'unavailable:0', 'unavailable:0']); + }); + + test('source admission accepts HTTP, HTTPS and file without silently limiting unchanged extensions to loopback', () => { + const values = ['http://127.0.0.1:3000/canvas', 'https://fixture.invalid/canvas', 'file:///workspace/canvas.html']; + assert.deepStrictEqual(values.map(validateCanvasPresentationUrl), values); + for (const value of ['javascript:alert(1)', 'data:text/html,test', 'https://user:secret@fixture.invalid/page', 'https:/missing-authority']) { + assert.throws(() => validateCanvasPresentationUrl(value)); + } + }); +}); + +suite('Session canvas logical references', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const reference: ISessionCanvasReference = { + providerId: 'local-agent-host', session: URI.parse('agent-host-copilotcli:/one'), + chat: URI.parse('agent-host-copilotcli:/one#peer/one'), canvas: URI.parse('ahp-canvas:/one'), + }; + + test('references distinguish providers, sessions, chats and canvases', () => { + const values = [ + reference, { ...reference, providerId: 'other-provider' }, { ...reference, session: URI.parse('agent-host-copilotcli:/two') }, + { ...reference, chat: URI.parse('agent-host-copilotcli:/one#peer/two') }, { ...reference, canvas: URI.parse('ahp-canvas:/two') }, + ]; + const resources = values.map(SessionCanvasUri.create); + assert.deepStrictEqual({ distinct: new Set(resources.map(resource => resource.toString())).size, parsed: resources.map(SessionCanvasUri.parse) }, { distinct: values.length, parsed: values }); + }); + + test('serialization and restoration retain only references and never source/effect state', async () => { + const resource = SessionCanvasUri.create(reference); + const input = store.add(new SessionCanvasInput(resource)); + const service = new class extends mock() { + override getInput(resource: URI) { return store.add(new SessionCanvasInput(resource)); } + }(); + const serializer = new SessionCanvasSerializer(service); + const instantiation = store.add(new TestInstantiationService()); + const serialized = serializer.serialize(input)!; + const restored = serializer.deserialize(instantiation, serialized); + await restored?.resolve(); + assert.deepStrictEqual({ serialized: JSON.parse(serialized), descriptor: restored?.toUntyped(), type: restored?.typeId }, + { serialized: { version: 1, resource: resource.toString() }, descriptor: { resource, options: { override: SessionCanvasInput.EDITOR_ID } }, type: SessionCanvasInput.ID }); + }); + + test('malformed references and presentation URLs cannot masquerade as durable canvas keys', () => { + for (const value of ['https://fixture.invalid/?token=secret', 'ahp-canvas:/one?token=secret', 'ahp-canvas://authority/one']) { + assert.throws(() => SessionCanvasUri.create({ ...reference, canvas: URI.parse(value) })); + } + const resource = SessionCanvasUri.create(reference); + assert.deepStrictEqual([ + SessionCanvasUri.parse(resource.with({ query: 'secret' })), SessionCanvasUri.parse(resource.with({ authority: 'other' })), + SessionCanvasUri.parse(URI.parse('vscode-session-canvas:/invalid')), + ], [undefined, undefined, undefined]); + }); +}); diff --git a/src/vs/sessions/contrib/canvases/test/common/sessionCanvasTestUtils.ts b/src/vs/sessions/contrib/canvases/test/common/sessionCanvasTestUtils.ts new file mode 100644 index 0000000000000..2c8f4f95ba1fb --- /dev/null +++ b/src/vs/sessions/contrib/canvases/test/common/sessionCanvasTestUtils.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../../../base/common/async.js'; +import type { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { observableValue, transaction } from '../../../../../base/common/observable.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasEntry, type CanvasState, type CanvasTypeDeclaration, type ISessionCanvases, type ResolveCanvasSourceResult, type SessionCanvasOpenOptions } from '../../../../services/sessions/common/sessionCanvases.js'; + +export function createCanvasState(chat = 'ahp-session:/one/chat/default', resource = 'ahp-canvas:/counter'): CanvasState { + return { + resource, title: 'Counter', revision: 1, + identity: { chat, source: { kind: CanvasSourceKind.Extension, extensionId: 'fixture.counter' }, canvasType: 'counter', instanceId: 'counter', incarnation: 'incarnation-1' }, + trust: { status: CanvasTrustStatus.Trusted }, + availability: { status: CanvasAvailabilityStatus.Ready, actions: [] }, + }; +} + +export function canvasEntry(state: CanvasState): CanvasEntry { + return { ...state, availability: state.availability.status }; +} + +export class TestSessionCanvases extends Disposable implements ISessionCanvases { + readonly availability = observableValue<'available' | 'unsupported' | 'disconnected'>(this, 'available'); + readonly generation = observableValue(this, 1); + readonly catalog = observableValue(this, []); + readonly entries = observableValue(this, []); + readonly initialized = observableValue(this, true); + readonly supportsInitialization = observableValue(this, true); + readonly initializing = observableValue(this, false); + readonly loading = observableValue(this, false); + readonly error = observableValue(this, undefined); + readonly state = observableValue(this, undefined); + readonly stateError = observableValue(this, undefined); + readonly sourceRequests: { readonly entry: CanvasEntry; readonly result: DeferredPromise }[] = []; + readonly effects: string[] = []; + readonly closes: CanvasEntry[] = []; + readonly restarts: CanvasEntry[] = []; + subscriptions = 0; + totalSubscriptions = 0; + openResult: DeferredPromise | undefined; + closeResult: DeferredPromise | undefined; + onRefresh: (() => Promise) | undefined; + onInitialize: ((token: CancellationToken) => Promise) | undefined; + + constructor(state = createCanvasState()) { + super(); + this.setState(state); + } + + setState(state: CanvasState): void { + transaction(tx => { + this.state.set(state, tx); + this.entries.set([canvasEntry(state)], tx); + }); + } + + async refresh(): Promise { await this.onRefresh?.(); } + + async initialize(token: CancellationToken): Promise { + this.effects.push('initialize'); + this.initializing.set(true, undefined); + try { + await this.onInitialize?.(token); + } finally { + this.initializing.set(false, undefined); + } + } + + observeCanvas() { + this.subscriptions++; + this.totalSubscriptions++; + let disposed = false; + return { + object: { state: this.state, error: this.stateError }, + dispose: () => { + if (!disposed) { + disposed = true; + this.subscriptions--; + } + }, + }; + } + + resolveSource(entry: CanvasEntry): Promise { + const result = new DeferredPromise(); + this.sourceRequests.push({ entry, result }); + return result.p; + } + + completeSource(index: number, url = 'http://127.0.0.1:43123/canvas', patch: Partial = {}): Promise { + const request = this.sourceRequests[index]; + return request.result.complete({ + availability: CanvasAvailabilityStatus.Ready, incarnation: request.entry.identity.incarnation, + revision: request.entry.revision, source: { url }, ...patch, + }); + } + + async open(_options: SessionCanvasOpenOptions): Promise { + this.effects.push('open'); + return this.openResult ? this.openResult.p : this.entries.get()[0]; + } + + async invokeAction() { this.effects.push('invoke'); return { result: undefined }; } + async close(entry: CanvasEntry): Promise { this.effects.push('close'); this.closes.push(entry); await this.closeResult?.p; } + async restart(entry: CanvasEntry): Promise { this.effects.push('restart'); this.restarts.push(entry); } +} diff --git a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasActions.test.ts b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasActions.test.ts new file mode 100644 index 0000000000000..42c198267a895 --- /dev/null +++ b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasActions.test.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, raceCancellationError, timeout } from '../../../../../base/common/async.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IDialogService, type IConfirmationResult } from '../../../../../platform/dialogs/common/dialogs.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IProgressService, Progress, ProgressLocation, type IProgress, type IProgressOptions, type IProgressStep } from '../../../../../platform/progress/common/progress.js'; +import { IQuickInputService, QuickInputHideReason, type IInputOptions, type IQuickInputHideEvent, type IQuickPick, type IQuickPickDidAcceptEvent, type IQuickPickItem, type QuickPickInput } from '../../../../../platform/quickinput/common/quickInput.js'; +import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; +import { CanvasSourceKind, SessionCanvasUri, type CanvasTypeDeclaration, type SessionCanvasOpenOptions } from '../../../../services/sessions/common/sessionCanvases.js'; +import { makeSession } from '../../../layout/test/browser/layoutControllerTestUtils.js'; +import { ISessionCanvasService, SessionCanvasInput, type ISessionCanvasTarget } from '../../common/sessionCanvas.js'; +import { canvasReferenceFromContext, SessionCanvasActions } from '../../electron-browser/sessionCanvasActions.js'; +import { createCanvasState, TestSessionCanvases } from '../common/sessionCanvasTestUtils.js'; + +suite('Session canvas command ownership', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const declaration: CanvasTypeDeclaration = { source: { kind: CanvasSourceKind.Extension, extensionId: 'fixture.counter' }, canvasType: 'counter', title: 'Counter', openInputSchema: { type: 'object' } }; + + function fixture() { + const instantiationService = store.add(new TestInstantiationService()); + const a = makeSession(URI.parse('session:/a')); + const b = makeSession(URI.parse('session:/b')); + const represented = observableValue('represented', a); + instantiationService.stub(ISessionContext, { session: represented }); + const canvases = store.add(new TestSessionCanvases()); + const otherCanvases = store.add(new TestSessionCanvases(createCanvasState('ahp-session:/b/chat/default', 'ahp-canvas:/b'))); + const target: ISessionCanvasTarget = { session: a, chat: a.mainChat.get(), canvases }; + const otherTarget: ISessionCanvasTarget = { session: b, chat: b.mainChat.get(), canvases: otherCanvases }; + const reference = { providerId: a.providerId, session: a.resource, chat: target.chat.resource, canvas: URI.parse(canvases.entries.get()[0].resource) }; + const opens: { target: ISessionCanvasTarget; options: SessionCanvasOpenOptions }[] = []; + instantiationService.stub(ISessionCanvasService, new class extends mock() { + override readonly enabled = observableValue(this, true); + override getTarget(session: URI, chat: URI) { + return [target, otherTarget].find(target => isEqual(target.session.resource, session) && isEqual(target.chat.resource, chat)); + } + override async open(target: ISessionCanvasTarget, options: SessionCanvasOpenOptions): Promise { + opens.push({ target, options }); + return SessionCanvasUri.create(reference); + } + }); + const accepted = store.add(new Emitter()); + const hidden = store.add(new Emitter()); + const input = new DeferredPromise(); + let inputOptions: IInputOptions | undefined; + let selectedLabel = 'Counter'; + let disposedPickers = 0; + let createdPickers = 0; + let pickerItems: () => readonly QuickPickInput[] = () => []; + instantiationService.stub(IQuickInputService, new class extends mock() { + override createQuickPick(options: { useSeparators: true }): IQuickPick; + override createQuickPick(options?: { useSeparators: boolean }): IQuickPick; + override createQuickPick(options?: { useSeparators: boolean }): IQuickPick | IQuickPick { + assert.strictEqual(options?.useSeparators, true); + createdPickers++; + const picker = new class extends mock>() { + override items: readonly QuickPickInput[] = []; + override get selectedItems(): readonly T[] { return this.items.filter((item): item is T => item.type !== 'separator' && item.label === selectedLabel); } + override readonly onDidAccept = accepted.event; + override readonly onDidHide = hidden.event; + override show(): void { } + override hide(): void { hidden.fire({ reason: QuickInputHideReason.Other }); } + override dispose(): void { disposedPickers++; } + }(); + pickerItems = () => picker.items; + return picker; + } + override input(options?: IInputOptions): Promise { + inputOptions = options; + return input.p; + } + }); + const confirmation = new DeferredPromise(); + instantiationService.stub(IDialogService, { confirm: () => confirmation.p }); + let progressOptions: IProgressOptions | undefined; + let cancelProgress: (() => void) | undefined; + instantiationService.stub(IProgressService, new class extends mock() { + override withProgress(options: IProgressOptions, task: (progress: IProgress) => Promise, onDidCancel?: () => void): Promise { + progressOptions = options; + cancelProgress = onDidCancel; + return task(Progress.None); + } + }); + const actions = instantiationService.createInstance(SessionCanvasActions); + return { + a, b, target, otherTarget, reference, represented, canvases, otherCanvases, actions, confirmation, input, opens, + inputOptions: () => inputOptions, pickerCounts: () => ({ created: createdPickers, disposed: disposedPickers }), + pickerLabels: () => pickerItems().map(item => item.label), progressOptions: () => progressOptions, + cancelProgress: () => cancelProgress?.(), + select: (label: string) => { selectedLabel = label; accepted.fire({ inBackground: false }); }, + hide: () => hidden.fire({ reason: QuickInputHideReason.Gesture }), + }; + } + + test('explicit session/chat targets and header contexts never fall back to a later active owner', () => { + const f = fixture(); + f.represented.set(f.b, undefined); + assert.deepStrictEqual([ + f.actions.resolveTarget(f.a).session.resource.toString(), + f.actions.resolveTarget({ sessionResource: f.a.resource.toString(), chatResource: f.a.mainChat.get().resource.toString() }).session.resource.toString(), + f.actions.resolveTarget(null).session.resource.toString(), + ], ['session:/a', 'session:/a', 'session:/b']); + assert.throws(() => f.actions.resolveTarget({ sessionResource: 'session:/missing', chatResource: f.a.mainChat.get().resource.toString() }), /Select a supported local conversation/); + }); + + test('initialization closes the pure picker before execution and returns to the same owner catalog', async () => { + const f = fixture(); + f.canvases.entries.set([], undefined); + let beforeExecution: ReturnType | undefined; + f.canvases.onInitialize = async () => { + beforeExecution = f.pickerCounts(); + f.canvases.catalog.set([declaration], undefined); + }; + const managing = f.actions.manage(); + const beforeSelection = [...f.canvases.effects]; + f.select('Initialize Canvas Providers'); + await timeout(0); + const labels = f.pickerLabels(); + f.hide(); + await managing; + assert.deepStrictEqual({ + beforeSelection, beforeExecution, effects: f.canvases.effects, catalogShown: labels.includes('Counter'), + picker: f.pickerCounts(), progress: { location: f.progressOptions()?.location, cancellable: f.progressOptions()?.cancellable }, + }, { + beforeSelection: [], beforeExecution: { created: 1, disposed: 1 }, effects: ['initialize'], catalogShown: true, + picker: { created: 2, disposed: 2 }, progress: { location: ProgressLocation.Notification, cancellable: true }, + }); + }); + + test('initialization stays with its captured owner and does not reopen a picker after navigation', async () => { + const f = fixture(); + const pending = new DeferredPromise(); + f.canvases.onInitialize = () => pending.p; + const managing = f.actions.manage(); + f.select('Initialize Canvas Providers'); + await timeout(0); + f.represented.set(f.b, undefined); + await pending.complete(); + await managing; + assert.deepStrictEqual({ + original: f.canvases.effects, other: f.otherCanvases.effects, picker: f.pickerCounts(), + }, { original: ['initialize'], other: [], picker: { created: 1, disposed: 1 } }); + }); + + test('progress cancellation cancels only the selected initialization and never repeats it', async () => { + const f = fixture(); + const pending = new DeferredPromise(); + let token = CancellationToken.None; + f.canvases.onInitialize = current => { token = current; return raceCancellationError(pending.p, current); }; + const managing = f.actions.manage(); + f.select('Initialize Canvas Providers'); + await timeout(0); + f.cancelProgress(); + await managing; + await pending.complete(); + assert.deepStrictEqual({ cancelled: token.isCancellationRequested, effects: f.canvases.effects, picker: f.pickerCounts() }, + { cancelled: true, effects: ['initialize'], picker: { created: 1, disposed: 1 } }); + }); + + test('an unsupported host offers only pure catalog browsing and initialization errors remain errors', async () => { + const f = fixture(); + f.canvases.supportsInitialization.set(false, undefined); + const browsing = f.actions.manage(); + const labels = f.pickerLabels(); + f.hide(); + await browsing; + const before = [...f.canvases.effects]; + f.canvases.supportsInitialization.set(true, undefined); + f.canvases.onInitialize = async () => { throw new Error('Controlled initialization failure'); }; + const rejected = assert.rejects(f.actions.manage(), /Controlled initialization failure/); + f.select('Initialize Canvas Providers'); + await rejected; + assert.deepStrictEqual({ + offeredInitialization: labels.includes('Initialize Canvas Providers'), before, effects: f.canvases.effects, picker: f.pickerCounts(), + }, { offeredInitialization: false, before: [], effects: ['initialize'], picker: { created: 2, disposed: 2 } }); + }); + + test('malformed explicit editor references cannot fall back to the active canvas', () => { + const f = fixture(); + const input = store.add(new SessionCanvasInput(SessionCanvasUri.create(f.reference))); + assert.deepStrictEqual([ + canvasReferenceFromContext(f.reference), + canvasReferenceFromContext(input), + canvasReferenceFromContext({ groupId: 1, editorIndex: 0 }), + canvasReferenceFromContext(undefined), + ], [f.reference, input.reference, undefined, undefined]); + assert.strictEqual(SessionCanvasUri.create(canvasReferenceFromContext(input.resource)!).toString(), input.resource.toString()); + assert.throws(() => canvasReferenceFromContext({ ...f.reference, session: f.reference.session.toString() }), /valid logical canvas reference/); + assert.throws(() => canvasReferenceFromContext({ ...f.reference, canvas: URI.parse('https://example.invalid/not-a-logical-canvas') }), /valid logical canvas reference/); + assert.throws(() => canvasReferenceFromContext(URI.file('/another-editor.txt')), /valid logical canvas reference/); + }); + + test('catalog selection and structured input remain attached to the captured owner through navigation', async () => { + const f = fixture(); + f.canvases.entries.set([], undefined); + f.canvases.catalog.set([declaration], undefined); + const managing = f.actions.manage(); + f.select('Counter'); + await timeout(0); + f.represented.set(f.b, undefined); + const validate = f.inputOptions()?.validateInput; + const invalid = await validate?.('{'); + const valid = await validate?.('{"count":3}'); + await f.input.complete('{"count":3}'); + await managing; + assert.deepStrictEqual({ + invalid: typeof invalid, valid, owner: f.opens[0].target.session.resource.toString(), input: f.opens[0].options.input, + type: f.opens[0].options.canvasType, picker: f.pickerCounts(), + }, { invalid: 'string', valid: undefined, owner: 'session:/a', input: { count: 3 }, type: 'counter', picker: { created: 1, disposed: 1 } }); + }); + + test('refresh stays in the same live picker and never starts or replays an effect', async () => { + const f = fixture(); + let refreshes = 0; + f.canvases.onRefresh = async () => { refreshes++; }; + const managing = f.actions.manage(); + f.select('Refresh Live Catalog'); + await timeout(0); + f.hide(); + await managing; + assert.deepStrictEqual({ refreshes, picker: f.pickerCounts(), effects: f.canvases.effects }, { refreshes: 2, picker: { created: 1, disposed: 1 }, effects: [] }); + }); + + test('restart retains the pre-confirmation incarnation and provider/chat target', async () => { + const f = fixture(); + const original = f.canvases.entries.get()[0]; + const restarting = f.actions.restart(f.reference); + f.represented.set(f.b, undefined); + f.canvases.setState({ ...createCanvasState(), revision: 2, identity: { ...original.identity, incarnation: 'replacement' } }); + await f.confirmation.complete({ confirmed: true }); + await restarting; + assert.deepStrictEqual({ restarted: f.canvases.restarts, otherEffects: f.otherCanvases.effects }, { restarted: [original], otherEffects: [] }); + }); + + test('cancelled restart and mismatched provider references produce no effect', async () => { + const f = fixture(); + const restarting = f.actions.restart(f.reference); + await f.confirmation.complete({ confirmed: false }); + await restarting; + await assert.rejects(f.actions.restart({ ...f.reference, providerId: 'another-provider' }), /owning chat/); + assert.deepStrictEqual(f.canvases.effects, []); + }); +}); diff --git a/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts new file mode 100644 index 0000000000000..e963194b480ca --- /dev/null +++ b/src/vs/sessions/contrib/canvases/test/electron-browser/sessionCanvasService.test.ts @@ -0,0 +1,457 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue, transaction } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../../../../workbench/contrib/browserView/common/browserView.js'; +import { IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { isResourceEditorInput } from '../../../../../workbench/common/editor.js'; +import { SessionCanvasesEnabledSettingId, SessionCanvasUri, type CanvasEntry, type ISessionCanvasReference } from '../../../../services/sessions/common/sessionCanvases.js'; +import type { ISessionCapabilities } from '../../../../services/sessions/common/session.js'; +import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { BaseLayoutController } from '../../../layout/browser/baseSessionLayoutController.js'; +import { SinglePaneLayoutController } from '../../../layout/browser/singlePaneLayoutController.js'; +import { createTestHarness, makeSession, type ICreateOptions } from '../../../layout/test/browser/layoutControllerTestUtils.js'; +import { ISessionCanvasService, SessionCanvasInput, SessionCanvasSerializer } from '../../common/sessionCanvas.js'; +import { SessionCanvasMount } from '../../common/sessionCanvasMount.js'; +import { SessionCanvasService } from '../../electron-browser/sessionCanvasService.js'; +import { canvasEntry, createCanvasState, TestSessionCanvases } from '../common/sessionCanvasTestUtils.js'; + +class CanvasTestLayoutController extends BaseLayoutController { } + +suite('Session canvas editor ownership and working sets', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function fixture(options: ICreateOptions = {}, enabled = true) { + const harness = createTestHarness(store.add(new DisposableStore()), { useModal: 'some', workspaceFolders: [{ uri: URI.file('/repo') }], ...options }); + const configuration = harness.instaService.get(IConfigurationService); + assert.ok(configuration instanceof TestConfigurationService); + if (enabled) { + configuration.setUserConfiguration(SessionCanvasesEnabledSettingId, true); + } + const aSession = makeSession(URI.parse('session:/a')); + const aCapabilities = observableValue(harness, { supportsMultipleChats: false, supportsCanvases: true }); + const aActiveChat = observableValue(harness, aSession.activeChat.get()); + const aChats = observableValue(harness, aSession.chats.get()); + const aArchived = observableValue(harness, false); + const a: IActiveSession = { ...aSession, capabilities: aCapabilities, activeChat: aActiveChat, chats: aChats, isArchived: aArchived }; + const b: IActiveSession = { ...makeSession(URI.parse('session:/b')), capabilities: observableValue(harness, { supportsMultipleChats: false, supportsCanvases: true }) }; + const canvases = store.add(new TestSessionCanvases(createCanvasState('ahp-session:/a/chat/default'))); + const background = store.add(new TestSessionCanvases(createCanvasState('ahp-session:/b/chat/default', 'ahp-canvas:/background'))); + const reference: ISessionCanvasReference = { providerId: a.providerId, session: a.resource, chat: a.mainChat.get().resource, canvas: URI.parse(canvases.entries.get()[0].resource) }; + harness.instaService.stub(ISessionsManagementService, 'getSession', (resource: URI) => [a, b].find(session => isEqual(session.resource, resource))); + harness.instaService.stub(ISessionsManagementService, 'getSessionCanvases', (session: URI, chat: URI) => + isEqual(session, a.resource) && isEqual(chat, a.mainChat.get().resource) ? canvases + : isEqual(session, b.resource) && isEqual(chat, b.mainChat.get().resource) ? background : undefined); + const sentimentChanged = store.add(new Emitter()); + const entitlement = new class extends mock() { + override readonly onDidChangeSentiment = sentimentChanged.event; + override sentiment = { hidden: false }; + }(); + harness.instaService.stub(IChatEntitlementService, entitlement); + const notifications: string[] = []; + harness.instaService.stub(INotificationService, { info: message => notifications.push(String(message)) }); + const native: { id: string; resource: URI; url: string; disposed: boolean; model: IBrowserViewModel }[] = []; + harness.instaService.stub(IBrowserViewWorkbenchService, { + getOrCreateExternalBrowserView: async (id, resource, url) => { + const willDispose = store.add(new Emitter()); + const record = { id, resource, url, disposed: false }; + const model = new class extends mock() { + override readonly id = id; + override readonly onWillDispose = willDispose.event; + override dispose(): void { + if (!record.disposed) { + record.disposed = true; + willDispose.fire(); + } + } + }(); + native.push(Object.assign(record, { model })); + return model; + }, + }); + const opened: { input: SessionCanvasInput; options: IEditorOptions | undefined }[] = []; + if (!options.activateAux) { + harness.instaService.stub(IEditorService, 'openEditor', async (input: SessionCanvasInput, options?: IEditorOptions) => { + opened.push({ input, options }); + return undefined; + }); + } + const service = store.add(harness.instaService.createInstance(SessionCanvasService)); + harness.instaService.stub(ISessionCanvasService, service); + const input = service.getInput(SessionCanvasUri.create(reference)); + const currentInput = observableValue('input', input); + const visible = observableValue('visible', true); + const mount = store.add(new SessionCanvasMount(service, currentInput, visible, mainWindow.vscodeWindowId)); + return { harness, a, b, aCapabilities, aActiveChat, aChats, aArchived, canvases, background, reference, service, input, currentInput, visible, mount, native, opened, entitlement, sentimentChanged, notifications }; + } + + async function settleLayout(): Promise { + for (let i = 0; i < 6; i++) { + await timeout(0); + } + } + + async function singlePaneFixture() { + const f = fixture({ activateAux: true, singlePaneLayoutEnabled: true }); + f.visible.set(false, undefined); + store.add(f.harness.instaService.createInstance(SinglePaneLayoutController)); + store.add(f.harness.onDidChangePartVisibility.event(event => { + if (event.partId === Parts.EDITOR_PART) { + f.visible.set(event.visible, undefined); + } + })); + store.add(f.harness.onDidCloseEditor.event(event => { + if (event.editor === f.currentInput.get()) { + f.currentInput.set(undefined, undefined); + event.editor.dispose(); + } + })); + await settleLayout(); + f.harness.activeSessionObs.set(f.a, undefined); + await settleLayout(); + f.harness.activeGroupEditors.splice(1, 0, f.input); + f.harness.visibleEditorsList = [f.input]; + f.harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + f.harness.layoutService.setPartHidden(false, Parts.EDITOR_PART); + f.visible.set(true, undefined); + f.harness.onDidEditorsChange.fire(); + await f.canvases.completeSource(0); + await settleLayout(); + return f; + } + + for (const hydration of ['before', 'after']) { + test(`restored inputs hydrate titles when entries arrive ${hydration} input creation`, () => { + const f = fixture(); + const entry = f.canvases.entries.get()[0]; + const serializer = f.harness.instaService.createInstance(SessionCanvasSerializer); + const serialized = serializer.serialize(f.input); + assert.ok(serialized); + transaction(tx => { + f.visible.set(false, tx); + f.currentInput.set(undefined, tx); + f.canvases.entries.set([], tx); + f.canvases.initialized.set(false, tx); + f.harness.activeSessionObs.set(f.a, tx); + }); + f.input.dispose(); + const hydrate = () => transaction(tx => { + f.canvases.entries.set([entry], tx); + f.canvases.initialized.set(true, tx); + }); + if (hydration === 'before') { + hydrate(); + } + const restored = serializer.deserialize(f.harness.instaService, serialized); + assert.ok(restored instanceof SessionCanvasInput); + const initialName = restored.getName(); + const labels: string[] = []; + store.add(restored.onDidChangeLabel(() => labels.push(restored.getName()))); + if (hydration === 'after') { + hydrate(); + } + const hydratedName = restored.getName(); + f.canvases.entries.set([{ ...entry, title: 'Renamed counter' }], undefined); + assert.deepStrictEqual({ + initialName, hydratedName, renamed: restored.getName(), labels, + reused: f.service.getInput(restored.resource) === restored, + sourceRequests: f.canvases.sourceRequests.length, native: f.native.length, + effects: f.canvases.effects, opened: f.opened.length, + }, { + initialName: hydration === 'before' ? 'Counter' : 'Canvas', + hydratedName: 'Counter', renamed: 'Renamed counter', + labels: hydration === 'before' ? ['Renamed counter'] : ['Counter', 'Renamed counter'], + reused: true, sourceRequests: 0, native: 0, effects: [], opened: 0, + }); + }); + } + + test('input titles never use a different provider, chat or canvas membership', () => { + const f = fixture(); + const references = [ + { ...f.reference, providerId: 'different-provider' }, + { ...f.reference, chat: URI.parse('chat:/missing') }, + { ...f.reference, canvas: URI.parse('ahp-canvas:/missing') }, + ]; + assert.deepStrictEqual({ + titles: references.map(reference => f.service.getInput(SessionCanvasUri.create(reference)).getName()), + sourceRequests: f.canvases.sourceRequests.length, native: f.native.length, effects: f.canvases.effects, + }, { titles: ['Canvas', 'Canvas', 'Canvas'], sourceRequests: 0, native: 0, effects: [] }); + }); + + test('real layout-controller working-set swaps release and freshly remount the logical view without closing membership', async () => { + const f = fixture(); + store.add(f.harness.instaService.createInstance(CanvasTestLayoutController)); + f.harness.visibleEditorsList = [f.input]; + f.harness.activeGroupEditors = [f.input]; + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + f.harness.activeSessionObs.set(f.b, undefined); + await timeout(0); + const away = { nativeDisposed: f.native[0].disposed, mounted: !!f.mount.presentation.get(), members: f.canvases.entries.get().length }; + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(1, 'https://fixture.invalid/fresh-after-switch'); + await timeout(0); + assert.deepStrictEqual({ + away, workingSet: f.harness.applyWorkingSetCalls.at(-1), native: f.native.map(record => ({ url: record.url, disposed: record.disposed })), + effects: f.canvases.effects, logicalKey: f.input.resource.toString(), + }, { + away: { nativeDisposed: true, mounted: false, members: 1 }, + workingSet: { id: 'session-working-set:session:/a', name: 'session-working-set:session:/a' }, + native: [{ url: 'http://127.0.0.1:43123/canvas', disposed: true }, { url: 'https://fixture.invalid/fresh-after-switch', disposed: false }], + effects: [], logicalKey: SessionCanvasUri.create(f.reference).toString(), + }); + }); + + test('multi-session working sets detach the old native owner without changing shared editor visibility', async () => { + const f = fixture(); + f.harness.visibleSessionsObs.set([f.a, f.b], undefined); + store.add(f.harness.instaService.createInstance(CanvasTestLayoutController)); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + f.harness.applyWorkingSetCalls = []; + f.harness.setPartHiddenCalls = []; + f.harness.activeSessionObs.set(f.b, undefined); + await timeout(0); + assert.deepStrictEqual({ + mounted: !!f.mount.presentation.get(), disposed: f.native[0].disposed, effects: f.canvases.effects, workingSets: f.harness.applyWorkingSetCalls, visibility: f.harness.setPartHiddenCalls, + }, { mounted: false, disposed: true, effects: [], workingSets: ['empty'], visibility: [] }); + }); + + test('real Single-Pane whole-side-pane hiding retains the tab and membership but releases native resources', async () => { + const f = await singlePaneFixture(); + f.harness.closedEditors = []; + f.harness.layoutService.hideSidePane(); + await settleLayout(); + const hidden = { + nativeDisposed: f.native[0].disposed, inputDisposed: f.input.isDisposed(), members: f.canvases.entries.get().length, + tabKept: f.harness.activeGroupEditors.includes(f.input), closed: f.harness.closedEditors.length, + }; + f.harness.layoutService.toggleSidePane(); + await f.canvases.completeSource(1); + await settleLayout(); + assert.deepStrictEqual({ hidden, nativeCount: f.native.length, effects: f.canvases.effects }, + { hidden: { nativeDisposed: true, inputDisposed: false, members: 1, tabKept: true, closed: 0 }, nativeCount: 2, effects: [] }); + }); + + test('real Single-Pane Hide Editor restores its captured logical descriptor, not a source or effect', async () => { + const f = await singlePaneFixture(); + f.harness.layoutService.setPartHidden(true, Parts.EDITOR_PART); + await settleLayout(); + const hidden = { disposed: f.native[0].disposed, tabGone: !f.harness.activeGroupEditors.includes(f.input), members: f.canvases.entries.get().length }; + f.harness.layoutService.setPartHidden(false, Parts.EDITOR_PART); + await settleLayout(); + const descriptor = f.harness.openedEditors.find(editor => isResourceEditorInput(editor) && isEqual(editor.resource, f.input.resource)); + assert.ok(descriptor && isResourceEditorInput(descriptor) && descriptor.resource); + const restored = f.service.getInput(descriptor.resource); + f.currentInput.set(restored, undefined); + await f.canvases.completeSource(1, 'https://fixture.invalid/restored-by-controller'); + await settleLayout(); + assert.deepStrictEqual({ + hidden, resource: descriptor.resource.toString(), override: descriptor.options?.override, + disposed: f.native.map(record => record.disposed), effects: f.canvases.effects, + }, { + hidden: { disposed: true, tabGone: true, members: 1 }, resource: f.input.resource.toString(), override: SessionCanvasInput.EDITOR_ID, + disposed: [true, false], effects: [], + }); + }); + + test('tab disposal and descriptor-based Hide Editor restoration are detach-only', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + const resource = f.input.resource; + f.currentInput.set(undefined, undefined); + f.input.dispose(); + const restored = f.service.getInput(resource); + f.currentInput.set(restored, undefined); + await f.canvases.completeSource(1, 'https://fixture.invalid/restored'); + await timeout(0); + assert.deepStrictEqual({ resource: restored.resource, sameInput: restored === f.input, disposed: f.native.map(record => record.disposed), effects: f.canvases.effects }, + { resource, sameInput: false, disposed: [true, false], effects: [] }); + }); + + test('Close Canvas is a single guarded effect and releases the page before awaiting the reply', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + f.canvases.closeResult = new DeferredPromise(); + const closing = f.service.close(f.reference); + const pending = { disposed: f.native[0].disposed, mounted: !!f.mount.presentation.get(), closing: f.service.isClosing(f.reference) }; + await f.canvases.closeResult.complete(); + await closing; + assert.deepStrictEqual({ pending, effects: f.canvases.effects, closedRevision: f.canvases.closes[0].revision, inputDisposed: f.input.isDisposed() }, + { pending: { disposed: true, mounted: false, closing: true }, effects: ['close'], closedRevision: 1, inputDisposed: true }); + }); + + test('a failed logical close restores only the view and never retries the effect', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + f.canvases.closeResult = new DeferredPromise(); + const closing = f.service.close(f.reference); + const rejected = assert.rejects(closing, /Controlled/); + await f.canvases.closeResult.error(new Error('Controlled close failure')); + await rejected; + await f.canvases.completeSource(1); + await timeout(0); + assert.deepStrictEqual({ disposed: f.native.map(record => record.disposed), effects: f.canvases.effects, closing: f.service.isClosing(f.reference), inputDisposed: f.input.isDisposed() }, + { disposed: [true, false], effects: ['close'], closing: false, inputDisposed: false }); + }); + + test('closing a background canvas preserves the foreground native lease', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + const presentation = f.mount.presentation.get(); + f.background.closeResult = new DeferredPromise(); + const closing = f.service.close({ + providerId: f.b.providerId, session: f.b.resource, chat: f.b.mainChat.get().resource, + canvas: URI.parse(f.background.entries.get()[0].resource), + }); + const pending = { retained: f.mount.presentation.get() === presentation, disposed: f.native[0].disposed, reads: f.canvases.sourceRequests.length }; + await f.background.closeResult.complete(); + await closing; + await timeout(0); + + assert.deepStrictEqual({ + pending, retained: f.mount.presentation.get() === presentation, + native: f.native.map(record => record.disposed), reads: f.canvases.sourceRequests.length, + foregroundEffects: f.canvases.effects, backgroundEffects: f.background.effects, + }, { + pending: { retained: true, disposed: false, reads: 1 }, retained: true, + native: [false], reads: 1, foregroundEffects: [], backgroundEffects: ['close'], + }); + }); + + test('unrelated owner metadata changes do not remount the native view', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + const presentation = f.mount.presentation.get(); + f.aCapabilities.set({ ...f.aCapabilities.get(), supportsRename: true }, undefined); + f.aChats.set([...f.aChats.get()], undefined); + f.aActiveChat.set({ ...f.aActiveChat.get() }, undefined); + await timeout(0); + + assert.deepStrictEqual({ + retained: f.mount.presentation.get() === presentation, native: f.native.map(record => record.disposed), + reads: f.canvases.sourceRequests.length, effects: f.canvases.effects, + }, { retained: true, native: [false], reads: 1, effects: [] }); + }); + + test('native publication reveals only the currently represented chat, preserving conversation focus', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + const added = canvasEntry(createCanvasState('ahp-session:/a/chat/default', 'ahp-canvas:/new')); + f.canvases.entries.set([...f.canvases.entries.get(), added], undefined); + f.background.entries.set([canvasEntry(createCanvasState('ahp-session:/b/chat/default', 'ahp-canvas:/new-background'))], undefined); + await timeout(0); + assert.deepStrictEqual(f.opened.map(opened => ({ owner: opened.input.reference.session.toString(), canvas: opened.input.reference.canvas.toString(), preserveFocus: opened.options?.preserveFocus })), + [{ owner: f.a.resource.toString(), canvas: added.resource, preserveFocus: true }]); + }); + + test('an open that finishes after navigation stays in its captured owner without switching sessions', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + const target = f.service.getTarget(f.a.resource, f.a.mainChat.get().resource)!; + f.canvases.openResult = new DeferredPromise(); + const entry = f.canvases.entries.get()[0]; + const pending = f.service.open(target, { ...entry.identity, title: entry.title }); + f.harness.activeSessionObs.set(f.b, undefined); + await f.canvases.openResult.complete(entry); + await pending; + assert.deepStrictEqual({ active: f.harness.activeSessionObs.get()?.resource, opened: f.opened.length, notices: f.notifications.length, effects: f.canvases.effects }, + { active: f.b.resource, opened: 0, notices: 1, effects: ['open'] }); + }); + + test('AI hiding releases native resources without closing logical members', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + f.entitlement.sentiment = { hidden: true }; + f.sentimentChanged.fire(); + assert.deepStrictEqual({ disposed: f.native[0].disposed, inputDisposed: f.input.isDisposed(), members: f.canvases.entries.get().length, effects: f.canvases.effects }, + { disposed: true, inputDisposed: true, members: 1, effects: [] }); + }); + + test('capability rollback, peer navigation and archive each detach without modifying membership', async () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + await f.canvases.completeSource(0); + await timeout(0); + f.aCapabilities.set({ supportsMultipleChats: false, supportsCanvases: false }, undefined); + const capability = !!f.mount.presentation.get(); + f.aCapabilities.set({ supportsMultipleChats: false, supportsCanvases: true }, undefined); + await f.canvases.completeSource(1); + await timeout(0); + f.aActiveChat.set({ ...f.a.mainChat.get(), resource: URI.parse('session:/a/peer') }, undefined); + const peer = !!f.mount.presentation.get(); + f.aActiveChat.set(f.a.mainChat.get(), undefined); + await f.canvases.completeSource(2); + await timeout(0); + f.aArchived.set(true, undefined); + assert.deepStrictEqual({ + capability, peer, archived: !!f.mount.presentation.get(), disposed: f.native.map(record => record.disposed), members: f.canvases.entries.get().length, effects: f.canvases.effects, + }, { capability: false, peer: false, archived: false, disposed: [true, true, true], members: 1, effects: [] }); + }); + + test('absence of the presentation opt-in never mounts or exposes a target', () => { + const f = fixture({}, false); + f.harness.activeSessionObs.set(f.a, undefined); + assert.deepStrictEqual({ + enabled: f.service.enabled.get(), mounted: !!f.mount.presentation.get(), target: f.service.getTarget(f.a.resource, f.a.mainChat.get().resource), native: f.native.length, effects: f.canvases.effects, + }, { enabled: false, mounted: false, target: undefined, native: 0, effects: [] }); + }); + + test('chat hydration and removal reevaluate ownership even when the active-chat facade is unchanged', async () => { + const f = fixture(); + const chats = f.aChats.get(); + f.aChats.set([], undefined); + f.harness.activeSessionObs.set(f.a, undefined); + const before = { mounted: !!f.mount.presentation.get(), reads: f.canvases.sourceRequests.length }; + f.aChats.set(chats, undefined); + await f.canvases.completeSource(0); + await timeout(0); + f.canvases.entries.set([...f.canvases.entries.get(), canvasEntry(createCanvasState('ahp-session:/a/chat/default', 'ahp-canvas:/after-hydration'))], undefined); + await timeout(0); + f.aChats.set([], undefined); + assert.deepStrictEqual({ + before, mounted: !!f.mount.presentation.get(), disposed: f.native[0].disposed, published: f.opened.length, effects: f.canvases.effects, + }, { before: { mounted: false, reads: 0 }, mounted: false, disposed: true, published: 1, effects: [] }); + }); + + test('duplicate mounts and cross-window acquisition cannot clone native authority', () => { + const f = fixture(); + f.harness.activeSessionObs.set(f.a, undefined); + assert.deepStrictEqual({ + duplicate: f.service.acquirePresentation(f.input, mainWindow.vscodeWindowId), + otherWindow: f.service.acquirePresentation(f.input, mainWindow.vscodeWindowId + 1), + }, { duplicate: undefined, otherWindow: undefined }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 9318b75e26230..144298bc2a78e 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -52,6 +52,16 @@ Imported prompts retain Automation provenance through `MessageKind.Automation`. The browser fallback and host-owned executor both create sessions from this template. A draft restores it before the first `resolveSessionConfig` call and captures the provider-resolved state when saved. Initial values that are unavailable or policy-clamped remain saved preferences until the user explicitly changes them; the effective draft and every run still use current schema and managed-policy enforcement. +## Canvases + +The local desktop provider can project the optional provider-neutral [canvas facet](../../canvases/README.md) for supported Copilot CLI sessions when presentation is opted in and the connection negotiates canvas support. Other providers and agent types do not inherit that authority from a root capability alone. + +The provider owns exact session/chat resource translation and the canonical six-route canvas connection. Session state supplies logical membership; full canvas subscriptions and source pulls are separate. Catalog/source reads do not keep a session backing alive or initialize/recover a provider. Explicit effects use their captured revision/incarnation and unique request identity, without automatic retries. + +Collections follow session/chat membership and connection generations, including local host replacements that reuse the service object. Stable-ID draft promotion retains the collection; discarded/replaced drafts and removed chats/sessions release it. Editor visibility is not a provider-membership signal. + +Explicit open and provider initialization wait for the owning draft's configuration and eager AHP session creation. The provider promotes a canvas-first draft only when authoritative ready session state contains membership for a known chat or host-retained execution intent, and the host has published its actual session summary. Retained intent preserves an initialized owner even if no canvas was opened and the picker is subsequently dismissed. Promotion preserves the logical owner, selection, configuration, and canvas collection and acquires the running state lease before releasing the draft lease. It uses the normal replacement lifecycle without a model request; outstanding first-request preparation settles before that promotion. + ## Identity The local provider uses: diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionCanvases.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionCanvases.ts new file mode 100644 index 0000000000000..b31273e37f06e --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionCanvases.ts @@ -0,0 +1,283 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceCancellationError } from '../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../base/common/errors.js'; +import { structuralEquals } from '../../../../../base/common/equals.js'; +import { Disposable, DisposableStore, MutableDisposable, type IReference } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, observableValue, observableValueOpts, transaction, type IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { localize } from '../../../../../nls.js'; +import type { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { supportsAgentHostCanvasChatInitialization } from '../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; +import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { CanvasTrustStatus, type CanvasEntry, type CanvasState, type CanvasTypeDeclaration } from '../../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; +import type { OpenCanvasParams } from '../../../../../platform/agentHost/common/state/protocol/channels-canvas/commands.js'; +import { canvasIdentityEquals, type ISessionCanvases, type ISessionCanvasState, type SessionCanvasOpenOptions } from '../../../../services/sessions/common/sessionCanvases.js'; + +/** One authenticated connection incarnation, including local-host replacements that reuse the service object. */ +export interface IAgentHostCanvasBinding { + readonly connection: Pick & { + getSubscription(kind: StateComponents.Canvas, resource: URI, owner: string): IReference>; + }; +} + +export class AgentHostSessionCanvases extends Disposable implements ISessionCanvases { + readonly generation = observableValue(this, 0); + readonly catalog = observableValueOpts({ owner: this, equalsFn: structuralEquals }, []); + readonly entries: IObservable; + readonly initialized: IObservable; + readonly supportsInitialization: IObservable; + readonly initializing = observableValue(this, false); + readonly loading = observableValue(this, false); + readonly error = observableValue(this, undefined); + readonly availability: IObservable<'available' | 'unsupported' | 'disconnected'>; + private readonly disposed = observableValue(this, false); + private readonly initialization = this._register(new MutableDisposable()); + private refreshSequence = 0; + + constructor( + private readonly session: URI, + private readonly chat: URI, + private readonly binding: IObservable, + enabled: IObservable, + entries: IObservable, + private readonly keepAlive: () => void, + private readonly waitForSession?: () => Promise, + ) { + super(); + this.entries = derived(this, reader => entries.read(reader)?.filter(entry => entry.identity.chat === chat.toString()) ?? []); + this.initialized = derived(this, reader => entries.read(reader) !== undefined); + this.availability = derived(this, reader => { + if (this.disposed.read(reader) || !enabled.read(reader)) { + return 'unsupported'; + } + const connection = binding.read(reader)?.connection; + if (!connection) { + return 'disconnected'; + } + return connection.initializeResult.read(reader)?.canvases ? 'available' : 'unsupported'; + }); + this.supportsInitialization = derived(this, reader => this.availability.read(reader) === 'available' + && supportsAgentHostCanvasChatInitialization(binding.read(reader)?.connection.initializeResult.read(reader))); + this._register(autorun(reader => { + binding.read(reader); + this.availability.read(reader); + this.supportsInitialization.read(reader); + this.initialization.value?.cancel(); + this.refreshSequence++; + transaction(tx => { + this.generation.set(this.generation.read(undefined) + 1, tx); + this.catalog.set([], tx); + this.loading.set(false, tx); + this.error.set(undefined, tx); + }); + })); + } + + async initialize(token: CancellationToken): Promise { + if (token.isCancellationRequested) { + throw new CancellationError(); + } + const connection = this.currentConnection(); + if (!this.supportsInitialization.get()) { + throw new Error(localize('canvas.initializationUnsupported', "The connected host does not support explicit canvas provider initialization.")); + } + if (this.initializing.get()) { + throw new Error(localize('canvas.initializationInProgress', "Canvas providers are already being initialized for this conversation.")); + } + const generation = this.generation.get(); + const operation = new CancellationTokenSource(token); + this.initialization.value = operation; + this.initializing.set(true, undefined); + let dispatched = false; + try { + if (this.waitForSession) { + await raceCancellationError(this.waitForSession(), operation.token); + } + if (operation.token.isCancellationRequested) { + throw new CancellationError(); + } + this.keepAlive(); + dispatched = true; + await raceCancellationError(connection.initializeCanvasChat({ + channel: this.chat.toString(), requestId: generateUuid(), + }, operation.token), operation.token); + if (operation.token.isCancellationRequested) { + throw new CancellationError(); + } + await raceCancellationError(this.refresh(), operation.token); + } catch (error) { + if (generation !== this.generation.get()) { + throw new Error(dispatched + ? localize('canvas.initializationUncertain', "The canvas connection changed during initialization. Its outcome may be uncertain. Refresh the live catalog before retrying; no initialization was repeated.") + : localize('canvas.initializationOwnerChanged', "The canvas owner or connection changed before provider initialization."), { cause: error }); + } + throw error; + } finally { + this.initialization.clear(); + this.initializing.set(false, undefined); + } + } + + private currentConnection(): IAgentHostCanvasBinding['connection'] { + const connection = this.binding.get()?.connection; + if (this.availability.get() !== 'available' || !connection) { + throw new Error(localize('canvas.connectionUnavailable', "Canvases are unavailable for this chat. Check the local runtime and canvas preview availability.")); + } + return connection; + } + + private assertMember(canvas: CanvasEntry | CanvasState): void { + if (canvas.identity.chat !== this.chat.toString() || !this.entries.get().some(entry => entry.resource === canvas.resource)) { + throw new Error(localize('canvas.ownerMismatch', "This canvas does not belong to the selected chat.")); + } + } + + async refresh(): Promise { + const connection = this.currentConnection(); + const generation = this.generation.get(); + const sequence = ++this.refreshSequence; + transaction(tx => { + this.loading.set(true, tx); + this.error.set(undefined, tx); + }); + try { + const types: CanvasTypeDeclaration[] = []; + const cursors = new Set(); + let cursor: string | undefined; + do { + const result = await connection.listCanvasTypes({ channel: this.chat.toString(), cursor }); + if (generation !== this.generation.get() || sequence !== this.refreshSequence) { + throw new CancellationError(); + } + types.push(...result.types); + cursor = result.nextCursor; + if (cursor && (cursors.has(cursor) || cursors.size >= 100 || types.length > 10_000)) { + throw new Error(localize('canvas.catalogPagination', "The canvas catalog returned an invalid continuation.")); + } + if (cursor) { + cursors.add(cursor); + } + } while (cursor); + this.catalog.set(types, undefined); + } catch (error) { + if (generation === this.generation.get() && sequence === this.refreshSequence) { + this.error.set(error instanceof Error ? error : new Error(localize('canvas.catalogFailed', "The live canvas catalog could not be read."), { cause: error }), undefined); + } + throw error; + } finally { + if (sequence === this.refreshSequence) { + this.loading.set(false, undefined); + } + } + } + + observeCanvas(resource: string): IReference { + const store = new DisposableStore(); + const state = observableValue(store, undefined); + const error = observableValue(store, undefined); + store.add(autorun(reader => { + this.generation.read(reader); + const connection = this.binding.read(reader)?.connection; + state.set(undefined, undefined); + error.set(undefined, undefined); + if (this.availability.read(reader) !== 'available' || !connection) { + return; + } + let reference: IReference>; + try { + reference = reader.store.add(connection.getSubscription(StateComponents.Canvas, URI.parse(resource), 'AgentHostSessionCanvases')); + } catch (cause) { + error.set(new Error(localize('canvas.stateUnavailable', "The canvas state subscription could not be established."), { cause }), undefined); + return; + } + const accept = (value: CanvasState | Error | undefined) => { + if (value instanceof Error) { + transaction(tx => { state.set(undefined, tx); error.set(value, tx); }); + } else if (value?.resource === resource && value.identity.chat === this.chat.toString()) { + transaction(tx => { state.set(value, tx); error.set(undefined, tx); }); + } else { + transaction(tx => { + state.set(undefined, tx); + error.set(value ? new Error(localize('canvas.stateOwnerMismatch', "Canvas state does not match the requested owner.")) : undefined, tx); + }); + } + }; + reader.store.add(reference.object.onDidChange(accept)); + if (reference.object.onDidError) { + reader.store.add(reference.object.onDidError(accept)); + } + accept(reference.object.value); + })); + return { object: { state, error }, dispose: () => store.dispose() }; + } + + async open(options: SessionCanvasOpenOptions): Promise { + const connection = this.currentConnection(); + const generation = this.generation.get(); + if (this.waitForSession) { + await this.waitForSession(); + } + if (generation !== this.generation.get()) { + throw new Error(localize('canvas.openOwnerChanged', "The canvas owner or connection changed before opening the canvas.")); + } + this.keepAlive(); + const params: OpenCanvasParams = { + channel: this.session.toString(), + canvas: `ahp-canvas:/${generateUuid()}`, + identity: { chat: this.chat.toString(), source: options.source, canvasType: options.canvasType, instanceId: options.instanceId }, + title: options.title, icon: options.icon, input: options.input, + requestId: generateUuid(), + }; + const result = await connection.openCanvas(params); + if (generation !== this.generation.get()) { + throw new Error(localize('canvas.openUncertain', "The connection changed while opening the canvas. Its outcome is uncertain. Refresh the catalog; do not automatically repeat the open.")); + } + if (!canvasIdentityEquals(result.canvas.identity, params.identity) || URI.parse(result.canvas.resource).scheme !== 'ahp-canvas') { + throw new Error(localize('canvas.openOwnerMismatch', "The canvas response did not match the owning chat.")); + } + return result.canvas; + } + + resolveSource(canvas: CanvasEntry) { + this.assertMember(canvas); + return this.currentConnection().resolveCanvasSource({ channel: canvas.resource }); + } + + invokeAction(canvas: CanvasState, actionId: string, input?: unknown) { + this.assertMember(canvas); + if (canvas.trust.status !== CanvasTrustStatus.Trusted) { + throw new Error(localize('canvas.actionUntrusted', "The canvas provider has not been approved to execute actions.")); + } + const connection = this.currentConnection(); + this.keepAlive(); + return connection.invokeCanvasAction({ + channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: generateUuid(), actionId, input, + }); + } + + close(canvas: CanvasEntry): Promise { + this.assertMember(canvas); + const connection = this.currentConnection(); + return connection.closeCanvas({ channel: canvas.resource, revision: canvas.revision, requestId: generateUuid() }); + } + + restart(canvas: CanvasEntry): Promise { + this.assertMember(canvas); + const connection = this.currentConnection(); + this.keepAlive(); + return connection.restartCanvasProvider({ channel: canvas.resource, incarnation: canvas.identity.incarnation, requestId: generateUuid() }); + } + + override dispose(): void { + this.initialization.value?.cancel(); + this.disposed.set(true, undefined); + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 57418663f30b7..3f0eec9375996 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -9,7 +9,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { arrayEquals, structuralEquals } from '../../../../../base/common/equals.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { mapsStrictEqualIgnoreOrder } from '../../../../../base/common/map.js'; import { equals } from '../../../../../base/common/objects.js'; import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableFromEvent, observableValueOpts, subtransaction, transaction, waitForState, autorun, observableValue } from '../../../../../base/common/observable.js'; @@ -21,6 +21,9 @@ import { localize } from '../../../../../nls.js'; import { AgentSession, AuthenticateParams, AuthenticateResult, IAgentSessionMetadata, protectedResourcesRequireGitHubCopilotSignIn } from '../../../../../platform/agentHost/common/agent.js'; import { AgentMergeSessionOverrides, AgentMergeSessionState, readAgentMergeSessionState } from '../../../../../platform/agentHost/common/agentMerge.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import type { CanvasEntry } from '../../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; +import type { ISessionCanvases } from '../../../../services/sessions/common/sessionCanvases.js'; +import { AgentHostSessionCanvases, type IAgentHostCanvasBinding } from './agentHostSessionCanvases.js'; import type { AgentHostUriMapper } from '../../../../../platform/agentHost/common/agentHostUri.js'; import type { RemoteAgentHostConnectionStatus } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { AgentHostTransportFailureReason } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; @@ -34,9 +37,10 @@ import { KNOWN_MODE_VALUES, omitAutomationSessionTemplateConfigValues, SessionCo import { applyLegacyAutomationSessionConfig } from '../../../../../platform/agentHost/common/automationMigration.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; import { readAgentDevContainerWorktreeMetadata, withAgentDevContainerWorktreeMetadata, type IAgentDevContainerWorktreeMetadata } from '../../../../../platform/agentHost/common/meta/agentDevContainerWorktreeMeta.js'; +import { isCanvasSessionRetained } from '../../../../../platform/agentHost/common/meta/agentCanvasSessionMeta.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult, type SessionConfigPropertySchema } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationEnablementKind, CustomizationType, type CustomizationEnablement, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, type SessionActiveClient, SessionLifecycle, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType, type SessionSummaryChanges } from '../../../../../platform/agentHost/common/state/sessionActions.js'; import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, DEFAULT_CHAT_ID, getSessionChatResource, getSessionRelatedPullRequestUrls, isDefaultChatUri, isSessionStatusArchived, isSessionStatusRead, parseChatUri, readSessionCreationReference, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, ROOT_STATE_URI, SESSION_META_MULTI_ROOT_KEY, SessionMeta, SessionSourceControlOutcome, StateComponents, withSessionCreationReference, withSessionExternal, withSessionGitHubState, withSessionMultiRootMetadata, withSessionStatusFlag, withSessionWorkspaceless, type ChatState, type ChatSummary, type ISessionCreationReference as IProtocolSessionCreationReference, type ISessionGitHubState, type ISessionGitState, type ISessionMultiRootMetadata } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -556,6 +560,7 @@ export interface IAgentHostAdapterOptions { readonly getConnection: () => IAgentConnection | undefined; /** Agent capability lookup shared by every adapter owned by this provider. */ readonly agentCapabilities: IObservable | undefined>; + readonly canvasSupported?: (sessionId: string, reader: IReader) => boolean; /** * The scheme the host addresses this session under, when it differs from the agent provider * (cloud sandbox: provider `copilot`, sessions `ahp-session:/`). Defaults to the provider. @@ -743,13 +748,13 @@ class AdditionalChat extends Disposable { this._title.set(title || localize('newChatTab', "New Chat"), undefined); } - /** Present as `Untitled` until the first request is sent so the view shows the composer. */ + /** Present an empty chat as an untitled composer. */ markNew(): void { this._isNew.set(true, undefined); } - /** Clear the `new` presentation after the first request is sent. */ - markSent(): void { + /** Use the host-reported status after the chat has content. */ + markCreated(): void { this._isNew.set(false, undefined); } @@ -1194,6 +1199,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { supportsSideChat: agentCapabilities?.multipleChats?.sideChat ?? false, supportsRename: true, supportsDelete: true, + supportsCanvases: this.agentProvider === 'copilotcli' && (this._options.canvasSupported?.(this.sessionId, reader) ?? false), }; }); } @@ -1309,6 +1315,9 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { } else { entry.update(summary); } + if (state.lifecycle === SessionLifecycle.Ready && state.canvases?.some(canvas => canvas.identity.chat === summary.resource)) { + this.markChatAsCreated(chatId); + } ordered.push(entry.chat); } @@ -1364,16 +1373,16 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { : this.resource; } - /** Mark a peer chat new so it shows as `Untitled` until its first request. */ + /** Present an empty peer chat as an untitled composer. */ markChatAsNew(chatId: string): void { this._newChatIds.add(chatId); this._additionalChats.get(chatId)?.markNew(); } - /** Clear the `new` flag after the chat's first request is sent. */ - markChatAsSent(chatId: string): void { + /** Clear the empty-chat presentation after its first request or canvas membership. */ + markChatAsCreated(chatId: string): void { this._newChatIds.delete(chatId); - this._additionalChats.get(chatId)?.markSent(); + this._additionalChats.get(chatId)?.markCreated(); } setChatModelId(chatResource: URI, modelId: string | undefined, source: ChatModelSource): void { @@ -2209,7 +2218,10 @@ class NewSession extends Disposable { lastTurnEnd, mainChat: this._mainChat, chats, - capabilities: constObservable({ supportsMultipleChats: false, supportsRename: true, supportsDelete: true }), + capabilities: derived(this, reader => ({ + supportsMultipleChats: false, supportsRename: true, supportsDelete: true, + supportsCanvases: this.agentProvider === 'copilotcli' && (this._options.canvasSupported?.(this.sessionId, reader) ?? false), + })), }; this.sessionId = this.session.sessionId; @@ -2601,6 +2613,9 @@ class NewSession extends Disposable { this.updateChangesets(initial.changesets); onSessionState(this.sessionId, initial); } + if (this.cancellationToken.isCancellationRequested) { + return; + } this._stateListener.value = ref.object.onDidChange(state => { this.updateChangesets(state.changesets); onSessionState(this.sessionId, state); @@ -2778,6 +2793,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * `customizations` and `activeClient.customizations` for the picker. */ protected readonly _lastSessionStates = new Map(); + protected readonly _canvasBinding = observableValue(this, undefined); + protected readonly _canvasEnabled = observableValue(this, false); + private readonly _canvasEntries = new Map>(); + private readonly _canvasCollections = this._register(new DisposableMap>()); + private readonly _canvasSupported = derived(this, reader => this._canvasEnabled.read(reader) + && !!this._canvasBinding.read(reader)?.connection.initializeResult.read(reader)?.canvases); /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map(); @@ -2878,6 +2899,99 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return this._sessionCache.get(rawId)?.backendUri ?? this._newSessions.get(sessionId)?.backendUri; } + getSessionCanvases(sessionId: string, chat: URI): ISessionCanvases | undefined { + const session = this.getKnownSessions().find(session => session.sessionId === sessionId); + const backendSession = this._getBackendSessionUri(sessionId); + if (!this._canvasEnabled.get() || !this._isCanvasExecutionSupported(sessionId) || !session || session.sessionType !== 'copilotcli' || !backendSession || !session.chats.get().some(candidate => isEqual(candidate.resource, chat))) { + return undefined; + } + const backendChat = this._resolveBackendSourceChatUri(sessionId, backendSession, chat); + const key = backendChat.toString(); + let collections = this._canvasCollections.get(sessionId); + if (!collections) { + collections = new DisposableMap(); + this._canvasCollections.set(sessionId, collections); + } + let collection = collections.get(key); + if (!collection) { + let entries = this._canvasEntries.get(sessionId); + if (!entries) { + const state = this._lastSessionStates.get(sessionId); + entries = observableValue(this, state ? state.canvases ?? [] : undefined); + this._canvasEntries.set(sessionId, entries); + } + const enabled = derived(this, reader => this._canvasEnabled.read(reader) && this._isCanvasExecutionSupported(sessionId, reader)); + collection = new AgentHostSessionCanvases(backendSession, backendChat, this._canvasBinding, enabled, entries, + () => this._keepSessionStateAlive(sessionId), () => this._waitForCanvasSession(sessionId)); + collections.set(key, collection); + } + return collection; + } + + protected _isCanvasExecutionSupported(_sessionId: string, _reader?: IReader): boolean { + return true; + } + + private async _waitForCanvasSession(sessionId: string): Promise { + const draft = this._getNewSession(sessionId); + if (!draft) { + return; + } + await waitForState(this.authenticationPending, pending => !pending, undefined, draft.cancellationToken); + await draft.waitForConfigurationReady(); + await draft.waitForEagerCreate(); + } + + private _tryPromoteCanvasSession(sessionId: string): void { + const draft = this._getNewSession(sessionId); + const state = this._lastSessionStates.get(sessionId); + if (!draft || draft.agentProvider !== 'copilotcli' || draft.session.status.get() !== SessionStatus.Untitled + || draft.session.isNewSessionRequestInProgress?.get() || state?.lifecycle !== SessionLifecycle.Ready + || (!isCanvasSessionRetained(state) && !state.canvases?.some(canvas => state.chats.some(chat => chat.resource === canvas.identity.chat)))) { + return; + } + const committed = this._sessionCache.get(AgentSession.id(draft.backendUri)); + if (!committed || !isEqual(committed.backendUri, draft.backendUri)) { + return; + } + + // Acquire the running lease before releasing the draft's subscription. + this._keepSessionStateAlive(sessionId); + this._preserveNewSessionConfig(draft, committed.sessionId); + const modelId = draft.getSelectedModelId(); + if (modelId) { + committed.setChatModelId(committed.resource, modelId, draft.session.mainChat.get().modelSource?.get() ?? ChatModelSource.Chosen); + } + const agent = draft.getSelectedAgent(); + if (agent) { + committed.setChatAgent(committed.resource, agent); + } + draft.graduate(); + this._newSessions.deleteAndDispose(sessionId); + this._onDidChangeDraftSessions.fire(); + this._onDidReplaceSession.fire({ from: draft.session, to: committed }); + this._syncActiveClient(); + } + + private _updateCanvasEntries(sessionId: string, state: SessionState): void { + this._canvasEntries.get(sessionId)?.set(state.canvases ?? [], undefined); + const chats = new Set(state.chats.map(chat => chat.resource)); + if (state.defaultChat) { + chats.add(state.defaultChat); + } + const collections = this._canvasCollections.get(sessionId); + for (const chat of collections?.keys() ?? []) { + if (!chats.has(chat)) { + collections?.deleteAndDispose(chat); + } + } + } + + private _disposeSessionCanvases(sessionId: string): void { + this._canvasCollections.deleteAndDispose(sessionId); + this._canvasEntries.delete(sessionId); + } + protected _hasSession(sessionId: string): boolean { const rawId = this._rawIdFromChatId(sessionId); return !!rawId && this._sessionCache.has(rawId); @@ -2890,6 +3004,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected _disposeAllNewSessions(): void { for (const sessionId of this._newSessions.keys()) { + this._disposeSessionCanvases(sessionId); this._onNewSessionAbandoned(sessionId, 'providerDisposed'); } this._newSessions.clearAndDisposeAll(); @@ -2898,6 +3013,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement deleteNewSession(sessionId: string): void { if (this._newSessions.has(sessionId)) { + this._disposeSessionCanvases(sessionId); this._onNewSessionAbandoned(sessionId, 'discarded'); this._newSessions.deleteAndDispose(sessionId); this._onDidChangeDraftSessions.fire(); @@ -3137,6 +3253,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement instantiationService: this._instantiationService, getConnection: () => this.connection, agentCapabilities: this._agentCapabilities, + canvasSupported: (sessionId, reader) => provider === 'copilotcli' && this._canvasSupported.read(reader) && this._isCanvasExecutionSupported(sessionId, reader), backendSessionScheme: this._backendSessionScheme(provider), mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), connectionStatus: this.remoteConnectionStatus, @@ -3576,7 +3693,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!newSession) { throw new Error('Cannot start a session that is no longer pending.'); } - return newSession.startRequest(activity); + return combinedDisposable(newSession.startRequest(activity), toDisposable(() => this._tryPromoteCanvasSession(sessionId))); } createQuickChat(sessionTypeId: string, options?: ISessionsProviderCreateSessionOptions): ISession { @@ -3652,6 +3769,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement agentCapabilities: this._agentCapabilities, mapBackendSessionResource: resource => this._mapBackendSessionResource(resource), connectionStatus: this.remoteConnectionStatus, + canvasSupported: (sessionId, reader) => sessionType.id === 'copilotcli' && this._canvasSupported.read(reader) && this._isCanvasExecutionSupported(sessionId, reader), ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions); } catch (err) { @@ -4323,6 +4441,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement clearSessionConfig(sessionId: string): void { if (this._newSessions.has(sessionId)) { + this._disposeSessionCanvases(sessionId); this._onNewSessionAbandoned(sessionId, 'discarded'); this._newSessions.deleteAndDispose(sessionId); this._onDidChangeDraftSessions.fire(); @@ -4856,6 +4975,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // `cached.chats`. this._keepSessionStateAlive(cached.sessionId); await connection.disposeChat(ahpChatUri); + this._canvasCollections.get(sessionId)?.deleteAndDispose(ahpChatUri.toString()); return true; } @@ -5105,7 +5225,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } // First request sent: revert to the host-reported status. - cached.markChatAsSent(chatResource.fragment); + cached.markChatAsCreated(chatResource.fragment); return cached; } @@ -5291,6 +5411,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // earlier), so the wire-level refcount stays positive. newSession.graduate(); if (this._newSessions.get(newSession.sessionId) === newSession) { + if (committedSession.sessionId !== newSession.sessionId) { + this._disposeSessionCanvases(newSession.sessionId); + } this._newSessions.deleteAndDispose(newSession.sessionId); this._onDidChangeDraftSessions.fire(); } @@ -5322,6 +5445,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // than risking a double-dispose race on transient failures. newSession.graduate(); if (this._newSessions.get(newSession.sessionId) === newSession) { + this._disposeSessionCanvases(newSession.sessionId); this._onNewSessionAbandoned(newSession.sessionId, 'sendFailed'); this._newSessions.deleteAndDispose(newSession.sessionId); this._onDidChangeDraftSessions.fire(); @@ -5673,6 +5797,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _applySessionStateUpdate(sessionId: string, state: SessionState): void { const previous = this._lastSessionStates.get(sessionId); this._lastSessionStates.set(sessionId, state); + this._updateCanvasEntries(sessionId, state); const previousAgentMerge = readAgentMergeSessionState(previous?.config?.values); const currentAgentMerge = readAgentMergeSessionState(state.config?.values); if (previousAgentMerge?.enabled !== currentAgentMerge?.enabled || !structuralEquals(previousAgentMerge?.overrides, currentAgentMerge?.overrides)) { @@ -5775,7 +5900,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleNewSessionStateUpdate(sessionId: string, state: SessionState): void { const previous = this._lastSessionStates.get(sessionId); this._lastSessionStates.set(sessionId, state); + this._updateCanvasEntries(sessionId, state); this._newSessions.get(sessionId)?.applySessionMeta(state._meta); + this._tryPromoteCanvasSession(sessionId); if (!previous || customizationsChanged(previous, state)) { this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire(); @@ -5789,6 +5916,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * back to the empty list rather than rendering stale agents. */ private _handleNewSessionStateGone(sessionId: string): void { + this._disposeSessionCanvases(sessionId); if (this._lastSessionStates.delete(sessionId)) { this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire(); @@ -6039,6 +6167,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._sessionCache.set(rawId, cached); added.push(cached); } + const cached = this._sessionCache.get(rawId); + if (cached) { + this._tryPromoteCanvasSession(cached.sessionId); + } } const removed: ISession[] = []; @@ -6286,12 +6418,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (this.updateAdapter(existing, meta)) { this._onDidChangeSessionsFromNotifications.fire({ added: [], removed: [], changed: [existing] }); } + this._tryPromoteCanvasSession(existing.sessionId); this._syncActiveClient(); return; } const cached = this.createAdapter(meta); this._sessionCache.set(rawId, cached); + this._tryPromoteCanvasSession(cached.sessionId); this._onDidChangeSessionsFromNotifications.fire({ added: [cached], removed: [], changed: [] }); this._syncActiveClient(); } @@ -6332,6 +6466,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._agentMergeSessionStateObservables.delete(stateOwner.sessionId); this._observedAgentMergeSessionStates.delete(stateOwner.sessionId); this._lastSessionStates.delete(stateOwner.sessionId); + this._disposeSessionCanvases(stateOwner.sessionId); return cached; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 73fceeacb9a05..c6411a9b856b0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -11,11 +11,13 @@ import { Event } from '../../../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; -import { autorun, constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { autorun, constObservable, IObservable, observableSignalFromEvent, type IReader } from '../../../../../base/common/observable.js'; import { basename, dirname, isEqualOrParent, joinPath, relativePath } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { isWeb } from '../../../../../base/common/platform.js'; +import { SessionCanvasesEnabledSettingId } from '../../../../services/sessions/common/sessionCanvases.js'; import { type AgentHostUriMapper, LOCAL_AGENT_HOST_AUTHORITY, toAgentHostContentUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; import { affectsAgentHostProviderPreference, IAgentConnection, IAgentHostService, shouldSurfaceLocalAgentHostProvider } from '../../../../../platform/agentHost/common/agentService.js'; @@ -124,6 +126,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide private readonly _devContainerAvailableDrafts = new Set(); private readonly _devContainerDrafts = new Set(); private readonly _pendingDevContainerEnablement = new Set(); + private readonly _canvasExecutionChanged = observableSignalFromEvent(this, Event.any(this._onDidChangeSessionConfig.event, this._onDidChangeDraftSessions.event)); readonly onDidChangeDevContainerAvailability: Event; override get order(): number { return -1; @@ -203,6 +206,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this.automations = automations; this._isSessionsWindow = environmentService.isSessionsWindow; + const updateCanvasPresentationEnabled = () => this._canvasEnabled.set( + this._isSessionsWindow && !isWeb && this._configurationService.getValue(SessionCanvasesEnabledSettingId) === true, undefined); + updateCanvasPresentationEnabled(); this.label = localize('localAgentHostLabel', "Local Agent Host"); @@ -254,6 +260,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide const connectionListeners = this._register(new DisposableStore()); const bindConnection = () => { connectionListeners.clear(); + this._canvasBinding.set({ connection: this._agentHostService }, undefined); automations.setConnection(this._agentHostService); this._attachConnectionListeners(this._agentHostService, connectionListeners); @@ -267,6 +274,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide bindConnection(); this._register(this._agentHostService.onAgentHostStart(bindConnection)); this._register(this._agentHostService.onAgentHostExit(() => { + this._canvasBinding.set(undefined, undefined); connectionListeners.clear(); automations.clearConnection(); })); @@ -289,6 +297,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide })); this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(SessionCanvasesEnabledSettingId)) { + updateCanvasPresentationEnabled(); + } if (e.affectsConfiguration('git.branchProtection')) { this._refreshSessionWorkspaces(); } @@ -311,11 +322,18 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide return session; } + protected override _isCanvasExecutionSupported(sessionId: string, reader?: IReader): boolean { + this._canvasExecutionChanged.read(reader); + return !this._devContainerDrafts.has(sessionId) && !this._pendingDevContainerEnablement.has(sessionId); + } + private async _resolveDevContainerAvailability(sessionId: string, workspaceUri: URI): Promise { try { const available = await this._devContainerAgentHostService.isAvailable(workspaceUri); if (!available || !this._getNewSession(sessionId)) { - this._pendingDevContainerEnablement.delete(sessionId); + if (this._pendingDevContainerEnablement.delete(sessionId)) { + this._onDidChangeSessionConfig.fire(sessionId); + } return; } this._devContainerAvailableDrafts.add(sessionId); @@ -324,7 +342,9 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide } this._onDidChangeSessionConfig.fire(sessionId); } catch (error) { - this._pendingDevContainerEnablement.delete(sessionId); + if (this._pendingDevContainerEnablement.delete(sessionId)) { + this._onDidChangeSessionConfig.fire(sessionId); + } this._logService.warn(`[${this.id}] Failed to resolve Dev Container availability for ${workspaceUri.toString()}`, error); } } @@ -347,10 +367,10 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide } if (this._devContainerAvailableDrafts.has(sessionId)) { this._enableDevContainer(sessionId); - this._onDidChangeSessionConfig.fire(sessionId); } else { this._pendingDevContainerEnablement.add(sessionId); } + this._onDidChangeSessionConfig.fire(sessionId); } setDevContainerEnabled(sessionId: string, enabled: boolean): void { diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionCanvases.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionCanvases.test.ts new file mode 100644 index 0000000000000..af991c4372a21 --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionCanvases.test.ts @@ -0,0 +1,336 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise } from '../../../../../../base/common/async.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 { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { getAgentHostExtensionInitializeResultMeta, type InitializeCanvasChatParams } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; +import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; +import type { CloseCanvasParams, InvokeCanvasActionParams, ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, RestartCanvasProviderParams } from '../../../../../../platform/agentHost/common/state/protocol/channels-canvas/commands.js'; +import { CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus, type CanvasEntry, type CanvasState, type CanvasTypeDeclaration } from '../../../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; +import { StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentHostSessionCanvases, type IAgentHostCanvasBinding } from '../../browser/agentHostSessionCanvases.js'; +import { canvasEntry, createCanvasState } from '../../../../canvases/test/common/sessionCanvasTestUtils.js'; + +type CanvasConnection = IAgentHostCanvasBinding['connection']; + +class TestCanvasConnection extends Disposable implements CanvasConnection { + readonly initializeResult = observableValue(this, { protocolVersion: '1', serverSeq: 0, snapshots: [], canvases: {}, _meta: getAgentHostExtensionInitializeResultMeta(true) }); + readonly changed = this._register(new Emitter()); + readonly failed = this._register(new Emitter()); + readonly listings: ListCanvasTypesParams[] = []; + readonly initializations: InitializeCanvasChatParams[] = []; + readonly resolutions: ResolveCanvasSourceParams[] = []; + readonly effects: (OpenCanvasParams | InvokeCanvasActionParams | CloseCanvasParams | RestartCanvasProviderParams)[] = []; + readonly subscriptions: string[] = []; + activeSubscriptions = 0; + state = createCanvasState(); + onList: ((params: ListCanvasTypesParams) => Promise) | undefined; + onOpen: ((params: OpenCanvasParams) => Promise) | undefined; + onInitialize: ((params: InitializeCanvasChatParams, token: CancellationToken) => Promise) | undefined; + subscribeError: Error | undefined; + + async initializeCanvasChat(params: InitializeCanvasChatParams, token = CancellationToken.None): Promise { + this.initializations.push(params); + await this.onInitialize?.(params, token); + } + + async listCanvasTypes(params: ListCanvasTypesParams): Promise { + this.listings.push(params); + return this.onList?.(params) ?? { types: [] }; + } + async openCanvas(params: OpenCanvasParams): Promise { + this.effects.push(params); + return this.onOpen?.(params) ?? { canvas: { ...canvasEntry(this.state), resource: params.canvas, identity: { ...params.identity, incarnation: this.state.identity.incarnation } } }; + } + async resolveCanvasSource(params: ResolveCanvasSourceParams) { + this.resolutions.push(params); + return { availability: CanvasAvailabilityStatus.Ready, revision: this.state.revision, incarnation: this.state.identity.incarnation, source: { url: 'https://fixture.invalid/current' } }; + } + async invokeCanvasAction(params: InvokeCanvasActionParams) { this.effects.push(params); return { result: 'reply' }; } + async closeCanvas(params: CloseCanvasParams): Promise { this.effects.push(params); } + async restartCanvasProvider(params: RestartCanvasProviderParams): Promise { this.effects.push(params); } + getSubscription(kind: StateComponents.Canvas, resource: URI) { + if (this.subscribeError) { + throw this.subscribeError; + } + assert.strictEqual(kind, StateComponents.Canvas); + this.subscriptions.push(resource.toString()); + this.activeSubscriptions++; + const connection = this; + return { + object: { + get value() { return connection.state; }, + get verifiedValue() { return connection.state; }, + onDidChange: this.changed.event, onDidError: this.failed.event, + onWillApplyAction: Event.None, onDidApplyAction: Event.None, + }, + dispose: () => this.activeSubscriptions--, + }; + } +} + +suite('Agent Host session canvas projection', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const session = URI.parse('ahp-session:/one'); + const chat = URI.parse('ahp-session:/one/chat/default'); + const declaration: CanvasTypeDeclaration = { source: { kind: CanvasSourceKind.Extension, extensionId: 'fixture.counter' }, canvasType: 'counter', title: 'Counter' }; + + function fixture(waitForSession?: () => Promise) { + const connection = store.add(new TestCanvasConnection()); + const binding = observableValue('connection', { connection }); + const enabled = observableValue('enabled', true); + const entries = observableValue('entries', [canvasEntry(connection.state)]); + let keepAlive = 0; + const canvases = store.add(new AgentHostSessionCanvases(session, chat, binding, enabled, entries, () => { keepAlive++; }, waitForSession)); + return { connection, binding, enabled, entries, canvases, keepAlive: () => keepAlive }; + } + + test('catalog and source reads route to the exact chat without keeping or starting a backing', async () => { + const { canvases, connection, keepAlive } = fixture(); + connection.onList = async params => params.cursor ? { types: [{ ...declaration, canvasType: 'second' }] } : { types: [declaration], nextCursor: 'page-2' }; + await canvases.refresh(); + await canvases.resolveSource(canvasEntry(connection.state)); + assert.deepStrictEqual({ + listings: connection.listings, resolutions: connection.resolutions, types: canvases.catalog.get().map(type => type.canvasType), + effects: connection.effects, initializations: connection.initializations, keepAlive: keepAlive(), + }, { + listings: [{ channel: chat.toString(), cursor: undefined }, { channel: chat.toString(), cursor: 'page-2' }], + resolutions: [{ channel: connection.state.resource }], types: ['counter', 'second'], effects: [], initializations: [], keepAlive: 0, + }); + }); + + test('explicit initialization waits for the exact owner then refreshes only its live catalog', async () => { + const ready = new DeferredPromise(); + const f = fixture(() => ready.p); + f.connection.onList = async () => ({ types: [declaration] }); + const initializing = f.canvases.initialize(CancellationToken.None); + const before = { initializations: f.connection.initializations.length, keepAlive: f.keepAlive(), busy: f.canvases.initializing.get() }; + await ready.complete(); + await initializing; + assert.deepStrictEqual({ + before, channels: f.connection.initializations.map(request => request.channel), + requestIds: new Set(f.connection.initializations.map(request => request.requestId)).size, + catalog: f.canvases.catalog.get(), busy: f.canvases.initializing.get(), effects: f.connection.effects, keepAlive: f.keepAlive(), + }, { + before: { initializations: 0, keepAlive: 0, busy: true }, channels: [chat.toString()], requestIds: 1, + catalog: [declaration], busy: false, effects: [], keepAlive: 1, + }); + }); + + test('initialization requires its explicit negotiated capability and a noncancelled request', async () => { + const f = fixture(); + await assert.rejects(f.canvases.initialize(CancellationToken.Cancelled), isCancellationError); + f.connection.initializeResult.set({ protocolVersion: '1', serverSeq: 0, snapshots: [], canvases: {} }, undefined); + await assert.rejects(f.canvases.initialize(CancellationToken.None), /does not support explicit/); + assert.deepStrictEqual({ + supported: f.canvases.supportsInitialization.get(), initializations: f.connection.initializations, keepAlive: f.keepAlive(), + }, { supported: false, initializations: [], keepAlive: 0 }); + }); + + test('connection loss while waiting for an owner cancels initialization before its effect', async () => { + const ready = new DeferredPromise(); + const f = fixture(() => ready.p); + const rejected = assert.rejects(f.canvases.initialize(CancellationToken.None), /changed before provider initialization/); + f.binding.set(undefined, undefined); + await rejected; + await ready.complete(); + assert.deepStrictEqual({ initializations: f.connection.initializations, busy: f.canvases.initializing.get(), keepAlive: f.keepAlive() }, + { initializations: [], busy: false, keepAlive: 0 }); + }); + + test('cancellation reaches the original initialization and concurrent calls cannot replace it', async () => { + const f = fixture(); + const pending = new DeferredPromise(); + const cancellation = store.add(new CancellationTokenSource()); + let requestToken = CancellationToken.None; + f.connection.onInitialize = async (_params, token) => { requestToken = token; await pending.p; }; + const rejected = assert.rejects(f.canvases.initialize(cancellation.token), isCancellationError); + await assert.rejects(f.canvases.initialize(CancellationToken.None), /already being initialized/); + cancellation.cancel(); + await rejected; + await pending.complete(); + assert.deepStrictEqual({ + requests: f.connection.initializations.length, cancelled: requestToken.isCancellationRequested, + busy: f.canvases.initializing.get(), listings: f.connection.listings, + }, { requests: 1, cancelled: true, busy: false, listings: [] }); + }); + + test('in-flight connection loss is an uncertain outcome, never an automatic initialization replay', async () => { + const f = fixture(); + const pending = new DeferredPromise(); + let requestToken = CancellationToken.None; + f.connection.onInitialize = async (_params, token) => { requestToken = token; await pending.p; }; + const rejected = assert.rejects(f.canvases.initialize(CancellationToken.None), /outcome may be uncertain/); + f.binding.set(undefined, undefined); + await rejected; + f.binding.set({ connection: f.connection }, undefined); + await pending.complete(); + assert.deepStrictEqual({ + requests: f.connection.initializations.length, cancelled: requestToken.isCancellationRequested, + supported: f.canvases.supportsInitialization.get(), busy: f.canvases.initializing.get(), listings: f.connection.listings, + }, { requests: 1, cancelled: true, supported: true, busy: false, listings: [] }); + }); + + test('disposing the owner cancels outstanding initialization without accepting a late catalog', async () => { + const f = fixture(); + const pending = new DeferredPromise(); + let requestToken = CancellationToken.None; + f.connection.onInitialize = async (_params, token) => { requestToken = token; await pending.p; }; + const rejected = assert.rejects(f.canvases.initialize(CancellationToken.None), /outcome may be uncertain/); + f.canvases.dispose(); + await rejected; + await pending.complete(); + assert.deepStrictEqual({ + cancelled: requestToken.isCancellationRequested, busy: f.canvases.initializing.get(), listings: f.connection.listings, + }, { cancelled: true, busy: false, listings: [] }); + }); + + test('logical close of an unloaded member does not resume its session backing', async () => { + const f = fixture(); + const entry = { ...canvasEntry(f.connection.state), availability: CanvasAvailabilityStatus.NotLoaded }; + f.entries.set([entry], undefined); + await f.canvases.close(entry); + assert.deepStrictEqual({ + keepAlive: f.keepAlive(), channels: f.connection.effects.map(effect => effect.channel), sourceReads: f.connection.resolutions, + }, { keepAlive: 0, channels: [entry.resource], sourceReads: [] }); + }); + + test('explicit canvas open waits for its owning session before issuing an effect', async () => { + const ready = new DeferredPromise(); + const f = fixture(() => ready.p); + const open = f.canvases.open({ ...declaration, instanceId: 'counter' }); + const before = { effects: f.connection.effects.length, keepAlive: f.keepAlive() }; + await ready.complete(); + const entry = await open; + assert.deepStrictEqual({ + before, channels: f.connection.effects.map(effect => effect.channel), keepAlive: f.keepAlive(), owner: entry.identity.chat, + }, { before: { effects: 0, keepAlive: 0 }, channels: [session.toString()], keepAlive: 1, owner: chat.toString() }); + }); + + test('a connection change while awaiting a canvas owner prevents the open effect', async () => { + const ready = new DeferredPromise(); + const f = fixture(() => ready.p); + const rejected = assert.rejects(f.canvases.open({ ...declaration, instanceId: 'counter' }), /changed before opening/); + f.binding.set({ connection: f.connection }, undefined); + await ready.complete(); + await rejected; + assert.deepStrictEqual({ effects: f.connection.effects, keepAlive: f.keepAlive() }, { effects: [], keepAlive: 0 }); + }); + + test('catalog refresh ordering suppresses earlier successful reads and earlier failures', async () => { + const { canvases, connection } = fixture(); + const first = new DeferredPromise(); + const second = new DeferredPromise(); + connection.onList = () => connection.listings.length === 1 ? first.p : second.p; + const earlier = canvases.refresh(); + const earlierFailure = assert.rejects(earlier); + const latest = canvases.refresh(); + await second.complete({ types: [declaration] }); + await latest; + await first.error(new Error('Controlled stale catalog failure')); + await earlierFailure; + assert.deepStrictEqual({ types: canvases.catalog.get(), error: canvases.error.get(), loading: canvases.loading.get() }, { types: [declaration], error: undefined, loading: false }); + }); + + test('repeated pagination cursors fail rather than loop', async () => { + const { canvases, connection } = fixture(); + connection.onList = async () => ({ types: [declaration], nextCursor: 'same' }); + await assert.rejects(canvases.refresh(), /continuation/); + assert.deepStrictEqual({ calls: connection.listings.length, loading: canvases.loading.get(), catalog: canvases.catalog.get() }, { calls: 2, loading: false, catalog: [] }); + }); + + test('new connection wrappers invalidate catalogs and full-state subscriptions even for a reused service', async () => { + const { canvases, connection, binding } = fixture(); + connection.onList = async () => ({ types: [declaration] }); + await canvases.refresh(); + const subscription = store.add(canvases.observeCanvas(connection.state.resource)); + const generation = canvases.generation.get(); + binding.set({ connection }, undefined); + assert.deepStrictEqual({ + generation: canvases.generation.get() - generation, catalog: canvases.catalog.get(), subscriptions: connection.subscriptions.length, + active: connection.activeSubscriptions, state: subscription.object.state.get()?.resource, + }, { generation: 1, catalog: [], subscriptions: 2, active: 1, state: connection.state.resource }); + }); + + test('disabling the preview and peers without negotiated capability release subscriptions', async () => { + const { canvases, connection, enabled } = fixture(); + const subscription = store.add(canvases.observeCanvas(connection.state.resource)); + enabled.set(false, undefined); + await assert.rejects(canvases.refresh(), /unavailable/); + enabled.set(true, undefined); + connection.initializeResult.set({ protocolVersion: '1', serverSeq: 0, snapshots: [] }, undefined); + assert.deepStrictEqual({ availability: canvases.availability.get(), active: connection.activeSubscriptions, state: subscription.object.state.get(), effects: connection.effects }, + { availability: 'unsupported', active: 0, state: undefined, effects: [] }); + }); + + test('membership and subscription state exclude siblings and clear mismatched updates', () => { + const { canvases, entries, connection } = fixture(); + const sibling = createCanvasState('ahp-session:/one/chat/peer', 'ahp-canvas:/sibling'); + entries.set([canvasEntry(connection.state), canvasEntry(sibling)], undefined); + const subscription = store.add(canvases.observeCanvas(connection.state.resource)); + connection.changed.fire(sibling); + assert.throws(() => canvases.resolveSource(canvasEntry(sibling)), /belong/); + assert.deepStrictEqual({ entries: canvases.entries.get().map(entry => entry.resource), state: subscription.object.state.get(), error: !!subscription.object.error.get() }, + { entries: [connection.state.resource], state: undefined, error: true }); + }); + + test('synchronous subscription loss is an observable failed state, not an uncaught autorun error', () => { + const { canvases, connection } = fixture(); + connection.subscribeError = new Error('Controlled connection loss'); + const subscription = store.add(canvases.observeCanvas(connection.state.resource)); + assert.deepStrictEqual({ state: subscription.object.state.get(), failed: !!subscription.object.error.get(), active: connection.activeSubscriptions }, { state: undefined, failed: true, active: 0 }); + }); + + test('all effect routes preserve observed owner, revision and incarnation with distinct request IDs', async () => { + const { canvases, connection } = fixture(); + const entry = canvasEntry(connection.state); + await canvases.open({ ...declaration, instanceId: 'new-instance', input: { count: 3 } }); + await canvases.invokeAction(connection.state, 'increment', { by: 1 }); + await canvases.close(entry); + await canvases.restart(entry); + const [open, invoke, close, restart] = connection.effects; + assert.deepStrictEqual({ + open: { ...open, canvas: '', requestId: '' }, + invoke: { ...invoke, requestId: '' }, close: { ...close, requestId: '' }, restart: { ...restart, requestId: '' }, + requests: new Set(connection.effects.map(effect => effect.requestId)).size, + }, { + open: { channel: session.toString(), canvas: '', identity: { chat: chat.toString(), source: declaration.source, canvasType: declaration.canvasType, instanceId: 'new-instance' }, title: 'Counter', icon: undefined, input: { count: 3 }, requestId: '' }, + invoke: { channel: entry.resource, actionId: 'increment', input: { by: 1 }, incarnation: entry.identity.incarnation, requestId: '' }, + close: { channel: entry.resource, revision: entry.revision, requestId: '' }, + restart: { channel: entry.resource, incarnation: entry.identity.incarnation, requestId: '' }, requests: 4, + }); + }); + + test('reconnect while opening reports an uncertain outcome without replay', async () => { + const { canvases, connection, binding } = fixture(); + const pending = new DeferredPromise(); + connection.onOpen = () => pending.p; + const opened = canvases.open({ ...declaration, instanceId: 'new' }); + const rejected = assert.rejects(opened, /uncertain/); + binding.set(undefined, undefined); + await pending.complete({ canvas: canvasEntry(connection.state) }); + await rejected; + assert.strictEqual(connection.effects.length, 1); + }); + + test('a provider cannot redirect an open response into a different logical identity', async () => { + const { canvases, connection } = fixture(); + connection.onOpen = async () => ({ canvas: { ...canvasEntry(connection.state), identity: { ...connection.state.identity, canvasType: 'another-type' } } }); + await assert.rejects(canvases.open({ ...declaration, instanceId: 'counter' }), /match/); + }); + + test('untrusted actions are not sent', () => { + const { canvases, connection } = fixture(); + assert.throws(() => canvases.invokeAction({ ...connection.state, trust: { status: CanvasTrustStatus.Pending } }, 'increment'), /approved/); + assert.deepStrictEqual(connection.effects, []); + }); +}); 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 eecb76a1045b4..c020967a6d80d 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 @@ -12,6 +12,7 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableMap, DisposableStore, ImmortalReference, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, ISettableObservable, observableFromEvent, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; +import { isWeb } from '../../../../../../base/common/platform.js'; import { URI } from '../../../../../../base/common/uri.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; @@ -19,11 +20,13 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSession, type IAgentCreateChatRequestOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agent.js'; import { AgentHostCodexAgentEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { withCanvasSessionRetained } from '../../../../../../platform/agentHost/common/meta/agentCanvasSessionMeta.js'; import { getAgentHostExtensionInitializeResultMeta } from '../../../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; import { AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY } from '../../../../../../platform/agentHost/common/automationMigration.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { InitializeResult } from '../../../../../../platform/agentHost/common/state/protocol/common/commands.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; +import type { ListCanvasTypesParams, OpenCanvasParams, OpenCanvasResult } from '../../../../../../platform/agentHost/common/state/protocol/channels-canvas/commands.js'; import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationEnablementKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentCustomization, type AgentInfo, type AutomationState, type ChangesSummary, type Customization, type RootState, type SessionActiveClient, type SessionConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, isAhpAutomationCatalogChannel, ResponsePartKind, SessionSourceControlOutcome, SessionStatus as ProtocolSessionStatus, StateComponents, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, withSessionCreationReference, withSessionEhcliAdoptable, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { SessionArtifactType, withSessionArtifacts } from '../../../../../../platform/agentHost/common/sessionArtifacts.js'; @@ -48,6 +51,7 @@ import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/co import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ISessionChangeEvent, ISessionsProvider, type ISessionsProviderCreateSessionOptions } from '../../../../../services/sessions/common/sessionsProvider.js'; +import { SessionCanvasesEnabledSettingId } from '../../../../../services/sessions/common/sessionCanvases.js'; import { ChatInteractivity, ChatModelSource, ChatOriginKind, getChatCapabilities, ISession, SessionStatus, TURN_CHANGES_CHANGESET_ID } from '../../../../../services/sessions/common/session.js'; import { IActiveSession, WorkspaceNotTrustedError } from '../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; @@ -69,6 +73,7 @@ import { IAgentHostSessionsProvider } from '../../../../../common/agentHostSessi import { IPathService } from '../../../../../../workbench/services/path/common/pathService.js'; import { MockLabelService } from '../../../../../../workbench/services/label/test/common/mockLabelService.js'; import { TestPathService } from '../../../../../../workbench/test/browser/workbenchTestServices.js'; +import { canvasEntry, createCanvasState } from '../../../../canvases/test/common/sessionCanvasTestUtils.js'; // ---- Mock IAgentHostService ------------------------------------------------- @@ -104,6 +109,17 @@ class MockAgentHostService extends mock() { }); override readonly clientId = 'test-local-client'; + readonly canvasListings: ListCanvasTypesParams[] = []; + override async listCanvasTypes(params: ListCanvasTypesParams) { + this.canvasListings.push(params); + return { types: [] }; + } + readonly canvasOpens: OpenCanvasParams[] = []; + override async openCanvas(params: OpenCanvasParams): Promise { + this.canvasOpens.push(params); + const canvas = canvasEntry(createCanvasState(params.identity.chat, params.canvas)); + return { canvas: { ...canvas, identity: { ...params.identity, incarnation: canvas.identity.incarnation } } }; + } private readonly _sessions = new Map(); public automationCatalog: AutomationState = { entries: [] }; public disposedSessions: URI[] = []; @@ -690,6 +706,299 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Provider identity ------- + const nativeCanvasTest = isWeb ? test.skip : test; + + test('canvas presentation follows the native platform boundary', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + agentHost.addSession(createSession('canvas-platform')); + const provider = createProvider(disposables, agentHost, undefined, { + configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }), + }); + await timeout(0); + const session = provider.getSessions()[0]; + assert.deepStrictEqual({ + supported: session.capabilities.get().supportsCanvases, + hasFacet: provider.getSessionCanvases(session.sessionId, session.mainChat.get().resource) !== undefined, + opens: agentHost.canvasOpens, + }, { supported: !isWeb, hasFacet: !isWeb, opens: [] }); + }); + + nativeCanvasTest('canvas capability requires the opt-in and negotiated runtime and rolls back on disconnect', async () => { + agentHost.addSession(createSession('canvas-capability')); + const configuration = new TestConfigurationService(); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: configuration }); + await timeout(0); + const session = provider.getSessions()[0]; + const states = [session.capabilities.get().supportsCanvases]; + configuration.setUserConfiguration(SessionCanvasesEnabledSettingId, true); + fireConfigChange(configuration, SessionCanvasesEnabledSettingId); + states.push(session.capabilities.get().supportsCanvases); + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + states.push(session.capabilities.get().supportsCanvases); + const canvases = provider.getSessionCanvases(session.sessionId, session.mainChat.get().resource)!; + const firstGeneration = canvases.generation.get(); + agentHost.fireAgentHostExit(); + states.push(session.capabilities.get().supportsCanvases); + const disconnected = canvases.availability.get(); + agentHost.fireAgentHostStart(); + states.push(session.capabilities.get().supportsCanvases); + configuration.setUserConfiguration(SessionCanvasesEnabledSettingId, false); + fireConfigChange(configuration, SessionCanvasesEnabledSettingId); + states.push(session.capabilities.get().supportsCanvases); + assert.deepStrictEqual({ + states, disconnected, changedGeneration: canvases.generation.get() > firstGeneration, + disabledFacet: provider.getSessionCanvases(session.sessionId, session.mainChat.get().resource), + }, { states: [false, false, true, false, true, false], disconnected: 'disconnected', changedGeneration: true, disabledFacet: undefined }); + }); + + test('canvas presentation remains unsupported outside Agents and for other local agent types', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + agentHost.addSession(createSession('canvas-workbench')); + agentHost.addSession(createSession('canvas-claude', { provider: 'claude' })); + const configuration = new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }); + const workbench = createProvider(disposables, agentHost, undefined, { configurationService: configuration, isSessionsWindow: false }); + const agents = createProvider(disposables, agentHost, undefined, { configurationService: configuration }); + await timeout(0); + const workbenchSession = workbench.getSessions().find(session => session.sessionType === 'copilotcli')!; + const claudeSession = agents.getSessions().find(session => session.sessionType === 'claude')!; + assert.deepStrictEqual({ + workbench: workbenchSession.capabilities.get().supportsCanvases, otherAgent: claudeSession.capabilities.get().supportsCanvases, + workbenchFacet: workbench.getSessionCanvases(workbenchSession.sessionId, workbenchSession.mainChat.get().resource), + otherAgentFacet: agents.getSessionCanvases(claudeSession.sessionId, claudeSession.mainChat.get().resource), + }, { workbench: false, otherAgent: false, workbenchFacet: undefined, otherAgentFacet: undefined }); + }); + + nativeCanvasTest('canvas presentation is disabled for pending and selected Dev Container execution', async () => { + const availability = new DeferredPromise(); + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { + configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }), + devContainerAgentHostService: new class extends mock() { + override async isAvailable(): Promise { return availability.p; } + }(), + }); + const draft = provider.createNewSession(URI.file('/home/user/project'), 'copilotcli'); + const canvases = provider.getSessionCanvases(draft.sessionId, draft.mainChat.get().resource)!; + const supported = [draft.capabilities.get().supportsCanvases]; + provider.preferDevContainer(draft.sessionId); + supported.push(draft.capabilities.get().supportsCanvases); + const pending = { availability: canvases.availability.get(), facet: provider.getSessionCanvases(draft.sessionId, draft.mainChat.get().resource) }; + await availability.complete(true); + await timeout(0); + supported.push(draft.capabilities.get().supportsCanvases); + provider.setDevContainerEnabled(draft.sessionId, false); + supported.push(draft.capabilities.get().supportsCanvases); + assert.deepStrictEqual({ supported, pending, restored: canvases.availability.get(), effects: agentHost.canvasOpens }, { + supported: [true, false, false, true], pending: { availability: 'unsupported', facet: undefined }, restored: 'available', effects: [], + }); + }); + + nativeCanvasTest('canvas mirror and catalog use exact peer resources without pure reads subscribing the session', async () => { + const rawId = 'canvas-peers'; + const backend = AgentSession.uri('copilotcli', rawId); + agentHost.addSession(createSession(rawId)); + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }) }); + await timeout(0); + const session = provider.getSessions()[0]; + const before = [...agentHost.sessionSubscribeCounts]; + const main = provider.getSessionCanvases(session.sessionId, session.mainChat.get().resource)!; + await main.refresh(); + const pureReadsSubscribed = JSON.stringify([...agentHost.sessionSubscribeCounts]) !== JSON.stringify(before); + provider.getSessionConfig(session.sessionId); + const defaultChat = buildDefaultChatUri(backend.toString()); + const peerChat = buildChatUri(backend.toString(), 'peer'); + const summary = (resource: string): ChatSummary => ({ resource, title: resource, status: ProtocolSessionStatus.Idle, modifiedAt: '2025-01-01T00:00:00.000Z' }); + const state: SessionState = { + provider: 'copilotcli', title: 'Canvases', status: ProtocolSessionStatus.Idle, lifecycle: SessionLifecycle.Ready, activeClients: [], + defaultChat, chats: [summary(defaultChat), summary(peerChat)], + canvases: [canvasEntry(createCanvasState(defaultChat)), canvasEntry(createCanvasState(peerChat, 'ahp-canvas:/peer'))], + }; + agentHost.setSessionState(rawId, 'copilotcli', state); + const peer = session.chats.get().find(chat => !isEqual(chat.resource, session.mainChat.get().resource))!; + const peerCanvases = provider.getSessionCanvases(session.sessionId, peer.resource)!; + await peerCanvases.refresh(); + const members = { main: main.entries.get().map(entry => entry.resource), peer: peerCanvases.entries.get().map(entry => entry.resource) }; + agentHost.setSessionState(rawId, 'copilotcli', { ...state, chats: [summary(defaultChat)], canvases: state.canvases?.slice(0, 1) }); + fireSessionRemoved(agentHost, rawId); + assert.deepStrictEqual({ + pureReadsSubscribed, members, listings: agentHost.canvasListings.map(list => list.channel), + removedPeer: peerCanvases.availability.get(), removedSession: main.availability.get(), + }, { + pureReadsSubscribed: false, members: { main: ['ahp-canvas:/counter'], peer: ['ahp-canvas:/peer'] }, listings: [defaultChat, peerChat], + removedPeer: 'unsupported', removedSession: 'unsupported', + }); + }); + + nativeCanvasTest('canvas draft projections are disposed on discard and config replacement', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }) }); + const availability: string[] = []; + for (const discard of [(id: string) => provider.deleteNewSession(id), (id: string) => provider.clearSessionConfig(id)]) { + const session = provider.createNewSession(URI.file('/home/user/project'), 'copilotcli'); + await timeout(0); + const canvases = provider.getSessionCanvases(session.sessionId, session.mainChat.get().resource)!; + discard(session.sessionId); + availability.push(canvases.availability.get()); + } + assert.deepStrictEqual(availability, ['unsupported', 'unsupported']); + }); + + test('ready canvas membership commits its exact new peer without a first turn', async () => { + const rawId = 'canvas-new-peer'; + const backend = AgentSession.uri('copilotcli', rawId); + const mainChat = buildDefaultChatUri(backend.toString()); + const summary = (resource: string): ChatSummary => ({ resource, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: '2025-01-01T00:00:00.000Z' }); + const initial: SessionState = { + provider: 'copilotcli', title: 'Canvas peers', status: ProtocolSessionStatus.Idle, lifecycle: SessionLifecycle.Ready, + activeClients: [], defaultChat: mainChat, chats: [summary(mainChat)], + }; + agentHost.addSession(createSession(rawId)); + agentHost.setSessionState(rawId, 'copilotcli', initial); + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }) }); + await timeout(0); + const session = provider.getSessions()[0]; + const peer = await provider.createNewChat(session.sessionId); + const backendPeer = agentHost.createdChats[0].chat.toString(); + const statuses = [peer.status.get()]; + const withCanvas: SessionState = { + ...initial, chats: [...initial.chats, summary(backendPeer)], + canvases: [canvasEntry(createCanvasState(backendPeer))], + }; + agentHost.setSessionState(rawId, 'copilotcli', { ...withCanvas, lifecycle: SessionLifecycle.Creating }); + statuses.push(peer.status.get()); + agentHost.setSessionState(rawId, 'copilotcli', withCanvas); + statuses.push(peer.status.get()); + const next = await provider.createNewChat(session.sessionId); + assert.deepStrictEqual({ + statuses, distinctNextChat: !isEqual(next.resource, peer.resource), nextStatus: next.status.get(), + samePeer: session.chats.get().find(chat => isEqual(chat.resource, peer.resource)) === peer, + }, { + statuses: [SessionStatus.Untitled, SessionStatus.Untitled, SessionStatus.Completed], + distinctNextChat: true, nextStatus: SessionStatus.Untitled, samePeer: true, + }); + }); + + for (const { metadataFirst, hasMember } of [ + { metadataFirst: true, hasMember: true }, + { metadataFirst: false, hasMember: true }, + { metadataFirst: true, hasMember: false }, + { metadataFirst: false, hasMember: false }, + ]) { + test(`canvas-first draft promotion preserves its owner and projection (${hasMember ? 'membership' : 'retained without members'}, ${metadataFirst ? 'summary first' : 'state first'})`, async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }) }); + await timeout(0); + const draft = provider.createNewSession(URI.file('/home/user/project'), 'copilotcli'); + provider.setModel(draft.sessionId, draft.mainChat.get().resource, 'selected-model', ChatModelSource.Chosen); + await timeout(0); + const backend = agentHost.createdSessionUris[0]; + const rawId = AgentSession.id(backend); + const chat = buildDefaultChatUri(backend.toString()); + const canvas = canvasEntry(createCanvasState(chat)); + const canvases = provider.getSessionCanvases(draft.sessionId, draft.mainChat.get().resource); + const replacements: string[] = []; + disposables.add(provider.onDidReplaceSession(e => replacements.push(`${e.from.sessionId}->${e.to.sessionId}`))); + const state: SessionState = { + provider: 'copilotcli', title: 'Canvas-first', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Creating, activeClients: [], defaultChat: chat, + chats: [{ resource: chat, title: 'Canvas-first', status: ProtocolSessionStatus.Idle, modifiedAt: '2025-01-01T00:00:00.000Z' }], + canvases: hasMember ? [canvas] : [], + _meta: hasMember ? undefined : withCanvasSessionRetained(undefined), + }; + if (metadataFirst) { + fireSessionAdded(agentHost, rawId, { title: 'Canvas-first' }); + } + agentHost.setSessionState(rawId, 'copilotcli', state); + const whileCreating = replacements.length; + agentHost.setSessionState(rawId, 'copilotcli', { ...state, lifecycle: SessionLifecycle.Ready, canvases: [], _meta: undefined }); + const beforeDurableIntent = replacements.length; + agentHost.setSessionState(rawId, 'copilotcli', { ...state, lifecycle: SessionLifecycle.Ready }); + const beforeSummary = replacements.length; + if (!metadataFirst) { + fireSessionAdded(agentHost, rawId, { title: 'Canvas-first' }); + } + provider.deleteNewSession(draft.sessionId); + const committed = provider.getSessionByResource(draft.resource)!; + const projection = provider.getSessionCanvases(committed.sessionId, committed.mainChat.get().resource); + assert.deepStrictEqual({ + whileCreating, beforeDurableIntent, beforeSummary, replacements, + owner: committed.resource.toString(), status: committed.status.get(), model: committed.modelId.get(), + projection: projection && { + members: projection.entries.get().map(entry => entry.resource), availability: projection.availability.get(), + sameProjection: projection === canvases, + }, + disposedSessions: agentHost.disposedSessions.map(resource => resource.toString()), + }, { + whileCreating: 0, beforeDurableIntent: 0, beforeSummary: metadataFirst ? 1 : 0, replacements: [`${draft.sessionId}->${draft.sessionId}`], + owner: draft.resource.toString(), status: SessionStatus.Completed, model: 'selected-model', + projection: isWeb ? undefined : { + members: hasMember ? [canvas.resource] : [], availability: 'available', sameProjection: true, + }, + disposedSessions: [], + }); + }); + } + + nativeCanvasTest('canvas open waits for eager owner creation without a model request', async () => { + const creation = new DeferredPromise(); + agentHost.onCreateSession = () => creation.p; + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }) }); + const draft = provider.createNewSession(URI.file('/home/user/project'), 'copilotcli'); + const canvases = provider.getSessionCanvases(draft.sessionId, draft.mainChat.get().resource)!; + const open = canvases.open({ ...createCanvasState().identity, title: 'Counter' }); + await timeout(0); + const before = agentHost.canvasOpens.length; + await creation.complete(); + await open; + assert.deepStrictEqual({ + before, channels: agentHost.canvasOpens.map(params => params.channel), + }, { before: 0, channels: agentHost.createdSessionUris.map(resource => resource.toString()) }); + }); + + test('canvas-first promotion waits for outstanding first-request preparation to settle', async () => { + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }) }); + await timeout(0); + const draft = provider.createNewSession(URI.file('/home/user/project'), 'copilotcli'); + const request = disposables.add(provider.startNewSessionRequest(draft.sessionId)); + await timeout(0); + const backend = agentHost.createdSessionUris[0]; + const rawId = AgentSession.id(backend); + const chat = buildDefaultChatUri(backend.toString()); + const replacements: string[] = []; + disposables.add(provider.onDidReplaceSession(e => replacements.push(e.to.sessionId))); + fireSessionAdded(agentHost, rawId); + agentHost.setSessionState(rawId, 'copilotcli', { + provider: 'copilotcli', title: 'Canvas-first', status: ProtocolSessionStatus.Idle, lifecycle: SessionLifecycle.Ready, + activeClients: [], defaultChat: chat, + chats: [{ resource: chat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: '2025-01-01T00:00:00.000Z' }], + canvases: [canvasEntry(createCanvasState(chat))], + }); + const before = replacements.length; + request.dispose(); + assert.deepStrictEqual({ before, replacements, disposedSessions: agentHost.disposedSessions }, { + before: 0, replacements: [draft.sessionId], disposedSessions: [], + }); + }); + + nativeCanvasTest('discarding an uncommitted canvas owner cancels a waiting open', async () => { + const creation = new DeferredPromise(); + agentHost.onCreateSession = () => creation.p; + agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), canvases: {} }, undefined); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: new TestConfigurationService({ [SessionCanvasesEnabledSettingId]: true }) }); + const draft = provider.createNewSession(URI.file('/home/user/project'), 'copilotcli'); + const canvases = provider.getSessionCanvases(draft.sessionId, draft.mainChat.get().resource)!; + const rejected = assert.rejects(canvases.open({ ...createCanvasState().identity, title: 'Counter' }), /Canceled/); + await timeout(0); + provider.deleteNewSession(draft.sessionId); + await creation.complete(); + await rejected; + assert.deepStrictEqual(agentHost.canvasOpens, []); + }); + test('Automation catalogue state follows local Agent Host connection lifetime', () => { agentHost.automationCatalog = { entries: [], _meta: { [AGENT_HOST_AUTOMATION_CATALOG_MIGRATED_META_KEY]: true } }; agentHost.initializeResult.set({ ...agentHost.initializeResult.get(), automations: { create: {} } }, undefined); @@ -3198,7 +3507,7 @@ suite('LocalAgentHostSessionsProvider', () => { await timeout(0); const session = provider.getSessions()[0]; - assert.deepStrictEqual(session?.capabilities.get(), { supportsRemoveArtifacts: false, supportsMultipleChats: false, supportsFork: true, supportsSideChat: false, supportsRename: true, supportsDelete: true }); + assert.deepStrictEqual(session?.capabilities.get(), { supportsRemoveArtifacts: false, supportsMultipleChats: false, supportsFork: true, supportsSideChat: false, supportsRename: true, supportsDelete: true, supportsCanvases: false }); })); test('restored quick chat collapses to a single chat even when state advertises peer chats', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -5898,7 +6207,7 @@ suite('LocalAgentHostSessionsProvider', () => { const peer = () => session.chats.get().find(c => c.resource.fragment === 'peer-1'); const whileNew = peer()!.status.get(); - (session as AgentHostSessionAdapter).markChatAsSent('peer-1'); + (session as AgentHostSessionAdapter).markChatAsCreated('peer-1'); const afterSent = peer()!.status.get(); assert.deepStrictEqual({ whileNew, afterSent }, { diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index fdde71a251881..25c00d768baa2 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -9,7 +9,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../base/com import { CancellationError } from '../../../../base/common/errors.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; -import { IObservable, observableValue } from '../../../../base/common/observable.js'; +import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; @@ -22,10 +22,11 @@ import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/co import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getSessionReferenceResource } from './sessionReference.js'; -import { ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IDeferredNewSessionRequestOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, ISessionsChangeEvent, ISessionsManagementService, NewSessionRequestOptions, WorkspaceNotTrustedError } from '../common/sessionsManagement.js'; +import { ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IDeferredNewSessionRequestOptions, IProviderSessionType, ISendRequestOptions, ISendRequestSentEvent, type ISessionLookupOptions, ISessionsChangeEvent, ISessionsManagementService, NewSessionRequestOptions, WorkspaceNotTrustedError } from '../common/sessionsManagement.js'; import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sessionsProvidersService.js'; import { IDeleteChatOptions, IPreparedNewSession, ISessionChangeEvent, ISessionsProvider, type ISessionsProviderCreateSessionOptions, type SessionResourceResolveReason } from '../common/sessionsProvider.js'; import { ChatModelSource, IChat, ISession, ISessionWorkspace, ISideChatSelection, SessionStatus, ISessionType } from '../common/session.js'; +import type { ISessionCanvases } from '../common/sessionCanvases.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; @@ -165,6 +166,16 @@ export class SessionsManagementService extends Disposable implements ISessionsMa } private _handleDidReplaceSession(from: ISession, to: ISession): void { + if (to.status.get() !== SessionStatus.Untitled) { + transaction(tx => { + if (this._newSession.get() === from) { + this._newSession.set(undefined, tx); + } + if (this._automationSession.get() === from) { + this._automationSession.set(undefined, tx); + } + }); + } this.chatWidgetHistoryService.moveHistory(ChatAgentLocation.Chat, from.sessionId, to.sessionId); // Notify the view service so it can update the visible grid slot. this._onDidReplaceSession.fire({ from, to }); @@ -272,14 +283,20 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return dedupeMigratedCopilotCliSessions(sessions, session => session.resource); } - getSession(resource: URI): ISession | undefined { + getSession(resource: URI, options?: ISessionLookupOptions): ISession | undefined { const unlistedSession = this._unlistedNewSessions.get(resource); if (unlistedSession) { return unlistedSession; } - return this._getMergedSessions().find(s => + const session = this._getMergedSessions().find(s => this.uriIdentityService.extUri.isEqual(s.resource, resource) ); + if (session || !options?.includeDrafts) { + return session; + } + return [this._newSession.get(), this._automationSession.get()].find(draft => + draft && this.uriIdentityService.extUri.isEqual(draft.resource, resource) + ); } getSessionForChatResource(resource: URI): { session: ISession; chat: IChat } | undefined { @@ -297,6 +314,14 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return undefined; } + getSessionCanvases(sessionResource: URI, chatResource: URI): ISessionCanvases | undefined { + const session = this.getSession(sessionResource, { includeDrafts: true }); + if (!session || !session.chats.get().some(chat => this.uriIdentityService.extUri.isEqual(chat.resource, chatResource))) { + return undefined; + } + return this._getProvider(session)?.getSessionCanvases?.(session.sessionId, chatResource); + } + getAllSessionTypes(): ISessionType[] { return [...this._sessionTypes]; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 6bb99d2934fa8..116b6db7d796b 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -848,6 +848,8 @@ export function toSessionId(providerId: string, resource: URI): string { * Consumers check these before surfacing session-specific features in the UI. */ export interface ISessionCapabilities { + /** Whether the current local runtime and client negotiate live canvas presentation. */ + readonly supportsCanvases?: boolean; /** Whether recorded artifacts can be removed from this session. */ readonly supportsRemoveArtifacts?: boolean; /** Whether this session supports multiple chats. */ diff --git a/src/vs/sessions/services/sessions/common/sessionCanvases.ts b/src/vs/sessions/services/sessions/common/sessionCanvases.ts new file mode 100644 index 0000000000000..7285a3ccac209 --- /dev/null +++ b/src/vs/sessions/services/sessions/common/sessionCanvases.ts @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { CancellationToken } from '../../../../base/common/cancellation.js'; +import type { IReference } from '../../../../base/common/lifecycle.js'; +import type { IObservable } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import type { InvokeCanvasActionResult, OpenCanvasParams, ResolveCanvasSourceResult } from '../../../../platform/agentHost/common/state/protocol/channels-canvas/commands.js'; +import { CanvasSourceKind, type CanvasEntry, type CanvasIdentityKey, type CanvasState, type CanvasTypeDeclaration } from '../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; + +export type { CanvasActionDeclaration, CanvasEntry, CanvasIdentityKey, CanvasSource, CanvasState, CanvasTypeDeclaration } from '../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; +export { CANVAS_INPUT_MAX_LENGTH, CanvasAvailabilityStatus, CanvasSourceKind, CanvasTrustStatus } from '../../../../platform/agentHost/common/state/protocol/channels-canvas/state.js'; +export type { ResolveCanvasSourceResult } from '../../../../platform/agentHost/common/state/protocol/channels-canvas/commands.js'; + +/** Presentation preference only; the execution host independently owns runtime admission. */ +export const SessionCanvasesEnabledSettingId = 'sessions.experimental.canvases.enabled'; + +export type SessionCanvasOpenOptions = Pick & Pick; + +/** Compare logical identity, excluding informational package versions and the live incarnation. */ +export function canvasIdentityEquals(left: CanvasIdentityKey, right: CanvasIdentityKey): boolean { + const sameSource = left.source.kind === CanvasSourceKind.Extension + ? right.source.kind === CanvasSourceKind.Extension && left.source.extensionId === right.source.extensionId + : right.source.kind === CanvasSourceKind.Package && left.source.sourceId === right.source.sourceId; + return sameSource && left.chat === right.chat && left.canvasType === right.canvasType && left.instanceId === right.instanceId; +} + +/** A live, read-only subscription to one logical canvas. */ +export interface ISessionCanvasState { + readonly state: IObservable; + readonly error: IObservable; +} + +/** One exact chat's live type catalog and logical membership, independent of editor visibility. */ +export interface ISessionCanvases { + readonly availability: IObservable<'available' | 'unsupported' | 'disconnected'>; + readonly generation: IObservable; + readonly catalog: IObservable; + readonly entries: IObservable; + /** Whether authoritative logical membership has been received. */ + readonly initialized: IObservable; + /** Whether the current connection supports explicit executable registry initialization. */ + readonly supportsInitialization: IObservable; + readonly initializing: IObservable; + readonly loading: IObservable; + readonly error: IObservable; + refresh(): Promise; + /** Initializes this chat's registry through normal execution admission, without creating a conversation turn. */ + initialize(token: CancellationToken): Promise; + observeCanvas(resource: string): IReference; + resolveSource(canvas: CanvasEntry): Promise; + open(options: SessionCanvasOpenOptions): Promise; + invokeAction(canvas: CanvasState, actionId: string, input?: unknown): Promise; + close(canvas: CanvasEntry): Promise; + restart(canvas: CanvasEntry): Promise; +} + +/** Provider-neutral editor ownership; no transient connection, source URL, or executable input. */ +export interface ISessionCanvasReference { + readonly providerId: string; + readonly session: URI; + readonly chat: URI; + readonly canvas: URI; +} + +export namespace SessionCanvasUri { + export const scheme = 'vscode-session-canvas'; + + export function create(reference: ISessionCanvasReference): URI { + if (!reference.providerId || reference.canvas.scheme !== 'ahp-canvas' || reference.canvas.authority || !reference.canvas.path + || reference.canvas.query || reference.canvas.fragment || [reference.session, reference.chat].some(resource => + !resource.scheme || ['http', 'https', 'file'].includes(resource.scheme) || resource.query || resource.authority.includes('@'))) { + throw new Error('Invalid logical canvas owner.'); + } + return URI.from({ + scheme, + path: '/' + [reference.providerId, reference.session.toString(), reference.chat.toString(), reference.canvas.toString()] + .map(part => encodeURIComponent(part)).join('/'), + }); + } + + export function parse(resource: URI): ISessionCanvasReference | undefined { + if (resource.scheme !== scheme || resource.authority || resource.query || resource.fragment) { + return undefined; + } + const parts = resource.path.slice(1).split('/'); + if (parts.length !== 4 || parts.some(part => !part)) { + return undefined; + } + try { + const [providerId, session, chat, canvas] = parts.map(part => decodeURIComponent(part)); + const reference: ISessionCanvasReference = { + providerId, session: URI.parse(session, true), chat: URI.parse(chat, true), canvas: URI.parse(canvas, true), + }; + return isEqual(create(reference), resource) ? reference : undefined; + } catch { + return undefined; + } + } +} diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 7510a636f1655..15b3679e41410 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -20,6 +20,7 @@ import { SessionIsStickyContext, SessionProviderIdContext, SessionSupportsDeleteContext, + SessionSupportsCanvasesContext, SessionSupportsMultipleChatsContext, SessionSupportsForkContext, SessionSupportsSideChatContext, @@ -54,6 +55,7 @@ interface ISessionContextKeys { readonly supportsSideChat: IContextKey; readonly supportsRename: IContextKey; readonly supportsDelete: IContextKey; + readonly supportsCanvases: IContextKey; readonly workspaceIsVirtual: IContextKey; readonly hasGitRepository: IContextKey; readonly hasChanges: IContextKey; @@ -97,6 +99,7 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey supportsSideChat: SessionSupportsSideChatContext.bindTo(contextKeyService), supportsRename: SessionSupportsRenameContext.bindTo(contextKeyService), supportsDelete: SessionSupportsDeleteContext.bindTo(contextKeyService), + supportsCanvases: SessionSupportsCanvasesContext.bindTo(contextKeyService), workspaceIsVirtual: SessionWorkspaceIsVirtualContext.bindTo(contextKeyService), hasGitRepository: SessionHasGitRepositoryContext.bindTo(contextKeyService), hasChanges: SessionHasChangesContext.bindTo(contextKeyService), @@ -150,6 +153,7 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS keys.supportsSideChat.set(capabilities?.supportsSideChat ?? false); keys.supportsRename.set(capabilities?.supportsRename ?? false); keys.supportsDelete.set(capabilities?.supportsDelete ?? false); + keys.supportsCanvases.set(capabilities?.supportsCanvases ?? false); const workspace = session?.workspace.read(reader); keys.workspaceIsVirtual.set(workspace?.isVirtualWorkspace ?? true); keys.hasGitRepository.set(session?.hasGitRepository?.read(reader) ?? workspace?.folders.some(folder => folder.gitRepository !== undefined) ?? false); diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index d79f30ba002f3..4de4ea08bfb68 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -10,6 +10,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IChat, ISession, ISessionType, ISessionWorkspace, ISideChatSelection } from './session.js'; +import type { ISessionCanvases } from './sessionCanvases.js'; import { IAutomationSessionConfiguration, IDeleteChatOptions, ISendRequestOptions as ISessionsProviderSendRequestOptions, type SessionResourceResolveReason } from './sessionsProvider.js'; /** Raised when unattended session creation targets a workspace that requires trust. */ @@ -222,6 +223,11 @@ export interface IRecentlyOpenedSessions { readonly other: ISession[]; } +export interface ISessionLookupOptions { + /** Include the currently owned composer and automation drafts. */ + readonly includeDrafts?: boolean; +} + /** * An active session item extends IChatSessionItem with repository information. * - For agent session items: repository is the workingDirectory from metadata @@ -245,7 +251,9 @@ export interface ISessionsManagementService { /** * Get a session by its resource URI. */ - getSession(resource: URI): ISession | undefined; + getSession(resource: URI, options?: ISessionLookupOptions): ISession | undefined; + /** Live canvas state owned by this exact session and chat, never a default-chat fallback. */ + getSessionCanvases(session: URI, chat: URI): ISessionCanvases | undefined; /** * Resolves a session resource to the one that should actually be opened. diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 72f3db9fe9d96..d73e27f03fe51 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -14,6 +14,7 @@ import { ModelIdentifierResolution } from '../../../../workbench/contrib/chat/co import { IAutomationDescriptor, IAutomationRun, IAutomationSessionTemplate } from '../../../../workbench/contrib/chat/common/automations/automation.js'; import { IAutomationStore } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ChatModelSource, IChat, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, ISideChatSelection } from './session.js'; +import type { ISessionCanvases } from './sessionCanvases.js'; /** * Event fired when sessions change within a provider. @@ -204,6 +205,8 @@ export interface ISessionsProvider { * List of all sessions currently known to the provider. Consumers should not cache this list, but should listen to `onDidChangeSessions` and update their cached list accordingly. */ getSessions(): ISession[]; + /** Live canvas state for the exact session and chat, when supported by this provider. */ + getSessionCanvases?(sessionId: string, chat: URI): ISessionCanvases | undefined; /** * Event that fires when sessions are added, removed, or changed. Consumers should update their session lists and any related UI when this occurs. */ 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 e9842a4fcde68..b46c6b4b87c39 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -163,6 +163,8 @@ class MockSessionStore implements ISessionsManagementService { return this._sessions.get(resource.toString()); } + getSessionCanvases(): undefined { return undefined; } + async resolveSessionResource(resource: URI): Promise { return resource; } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 71456b30073f9..75fd6644f798c 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -49,6 +49,7 @@ import { ISessionsProvidersService } from '../../browser/sessionsProvidersServic import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../common/agentHostSessionsProvider.js'; import { SessionsHasClosedItemContext } from '../../../../common/contextkeys.js'; import { COPILOT_CLI_EH_SCHEME, COPILOT_CLI_LOCAL_AH_SCHEME } from '../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; +import { ISessionCanvases } from '../../common/sessionCanvases.js'; const stubChat = { resource: URI.parse('test:///chat'), @@ -310,6 +311,101 @@ suite('SessionsManagementService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('getSessionCanvases routes exact peers to their provider and never falls back to the main chat', () => { + const peer = { ...stubChat, resource: URI.parse('test:/owned#peer') }; + const session = stubSession({ sessionId: 'canvases', providerId: 'test', chats: constObservable([stubChat, peer]) }); + const collection = new class extends mock() { }(); + const calls: { session: string; chat: string }[] = []; + const provider = new class extends TestSessionsProvider { + override getSessionCanvases(sessionId: string, chat: URI): ISessionCanvases { + calls.push({ session: sessionId, chat: chat.toString() }); + return collection; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + const found = service.getSessionCanvases(session.resource, peer.resource); + const unknownChat = service.getSessionCanvases(session.resource, URI.parse('test:/other#peer')); + const unknownSession = service.getSessionCanvases(URI.parse('test:/unknown'), peer.resource); + assert.deepStrictEqual({ found: found === collection, unknownChat, unknownSession, calls }, { + found: true, unknownChat: undefined, unknownSession: undefined, calls: [{ session: session.sessionId, chat: peer.resource.toString() }], + }); + }); + + test('getSessionCanvases leaves providers without the optional facet unsupported', () => { + const session = stubSession({ sessionId: 'no-canvases', providerId: 'test', chats: constObservable([stubChat]) }); + const { service } = createSessionsManagementService(session, disposables); + assert.strictEqual(service.getSessionCanvases(session.resource, stubChat.resource), undefined); + }); + + test('getSessionCanvases resolves the real composer draft before its first turn', async () => { + const draft = stubSession({ + sessionId: 'canvas-draft', providerId: 'test', status: constObservable(SessionStatus.Untitled), + chats: constObservable([stubChat]), + }); + const collection = new class extends mock() { }(); + const calls: string[] = []; + const provider = new class extends TestSessionsProvider { + override getSessions(): ISession[] { return []; } + override resolveWorkspace(folder: URI): ISessionWorkspace { + return { uri: folder, label: 'Test', icon: Codicon.folder, folders: [], requiresWorkspaceTrust: false, isVirtualWorkspace: false }; + } + override getSessionCanvases(sessionId: string, chat: URI): ISessionCanvases { + calls.push(`${sessionId}/${chat.toString()}`); + return collection; + } + }(draft); + const { service, view } = createSessionsManagementService(draft, disposables, provider); + await view.openNewSession({ folderUri: URI.parse('test:///folder') }); + + assert.deepStrictEqual({ + active: extUriBiasedIgnorePathCase.isEqual(view.activeSession.get()?.resource, draft.resource), + listed: service.getSessions(), + defaultLookup: service.getSession(draft.resource), + draftLookup: service.getSession(draft.resource, { includeDrafts: true }) === draft, + canvases: service.getSessionCanvases(draft.resource, stubChat.resource) === collection, + otherChat: service.getSessionCanvases(draft.resource, URI.parse('test:///other-chat')), + calls, + }, { + active: true, listed: [], defaultLookup: undefined, draftLookup: true, + canvases: true, otherChat: undefined, calls: [`${draft.sessionId}/${stubChat.resource.toString()}`], + }); + }); + + test('draft lookup follows replacement and disposal without changing catalog lookup', () => { + const drafts = ['first', 'automation', 'replacement'].map(sessionId => + stubSession({ sessionId, providerId: 'test', status: constObservable(SessionStatus.Untitled) })); + let nextDraft = 0; + const provider = new class extends TestSessionsProvider { + override getSessions(): ISession[] { return []; } + override createNewSession(): ISession { return drafts[nextDraft++]; } + override resolveWorkspace(folder: URI): ISessionWorkspace { + return { uri: folder, label: 'Test', icon: Codicon.folder, folders: [], requiresWorkspaceTrust: false, isVirtualWorkspace: false }; + } + }(drafts[0]); + const { service } = createSessionsManagementService(drafts[0], disposables, provider); + const folder = URI.parse('test:///folder'); + service.createNewSession(folder); + service.createAutomationSession(folder); + const lookup = () => drafts.map(draft => service.getSession(draft.resource, { includeDrafts: true })?.sessionId); + const states = [lookup()]; + service.createNewSession(folder); + states.push(lookup()); + service.discardNewSession(); + states.push(lookup()); + service.discardAutomationSession(drafts[1]); + states.push(lookup()); + + assert.deepStrictEqual({ states, catalog: service.getSessions() }, { + states: [ + ['first', 'automation', undefined], + [undefined, 'automation', 'replacement'], + [undefined, 'automation', undefined], + [undefined, undefined, undefined], + ], + catalog: [], + }); + }); + test('routes artifact removal to the owning provider and propagates errors', async () => { const session = stubSession({ sessionId: 'session', providerId: 'test', @@ -3457,6 +3553,38 @@ suite('SessionsManagementService', () => { assert.strictEqual(view.activeSession.get()?.resource.toString(), after.resource.toString()); }); + test('canvas-first replacement clears the pending draft without discarding the committed owner', async () => { + const draft = stubSession({ sessionId: 'canvas-first', providerId: 'test', status: constObservable(SessionStatus.Untitled) }); + const committed = { ...draft, status: constObservable(SessionStatus.Completed) }; + const onDidReplaceSession = disposables.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); + const discarded: string[] = []; + let sent = 0; + const provider = new class extends TestSessionsProvider { + override readonly onDidReplaceSession = onDidReplaceSession.event; + constructor() { super(draft); } + override resolveWorkspace(folder: URI): ISessionWorkspace { + return { uri: folder, label: 'Test', icon: Codicon.folder, folders: [], requiresWorkspaceTrust: false, isVirtualWorkspace: false }; + } + override deleteNewSession(sessionId: string): void { discarded.push(sessionId); } + override async sendRequest(): Promise { sent++; return committed; } + }; + const { service, view } = createSessionsManagementService(draft, disposables, provider); + let discardedEvents = 0; + disposables.add(service.onDidDiscardNewSession(() => discardedEvents++)); + await view.openNewSession({ folderUri: URI.parse('test:///folder') }); + const before = service.newSession.get()?.sessionId; + onDidReplaceSession.fire({ from: draft, to: committed }); + service.discardNewSession(draft); + + assert.deepStrictEqual({ + before, pending: service.newSession.get(), active: view.activeSession.get()?.sessionId, + status: view.activeSession.get()?.status.get(), discarded, discardedEvents, sent, + }, { + before: draft.sessionId, pending: undefined, active: committed.sessionId, + status: SessionStatus.Completed, discarded: [], discardedEvents: 0, sent: 0, + }); + }); + test('replacing a non-active session leaves the active session unchanged', async () => { const active = stubSession({ sessionId: 'active', providerId: 'test' }); const draft = stubSession({ sessionId: 'draft', providerId: 'test' }); diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 15b8957839661..fe75cb96b83b9 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -24,6 +24,7 @@ import '../workbench/electron-browser/desktop.contribution.js'; // Per-session layout controller (desktop / web desktop layout). import './contrib/layout/browser/sessions.layout.contribution.js'; +import './contrib/canvases/electron-browser/sessionCanvases.contribution.js'; //#endregion diff --git a/src/vs/workbench/api/test/electron-browser/mainThreadExternalBrowsers.test.ts b/src/vs/workbench/api/test/electron-browser/mainThreadExternalBrowsers.test.ts new file mode 100644 index 0000000000000..5ab2da212cd13 --- /dev/null +++ b/src/vs/workbench/api/test/electron-browser/mainThreadExternalBrowsers.test.ts @@ -0,0 +1,185 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { timeout } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IChannel, ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { browserZoomDefaultIndex, BrowserViewStorageScope, IBrowserViewCreateOptions, IBrowserViewCreatedEvent, IBrowserViewInfo, IBrowserViewService, validateBrowserViewReuse } from '../../../../platform/browserView/common/browserView.js'; +import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; +import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { MainThreadBrowsers } from '../../browser/mainThreadBrowsers.js'; +import { BrowserTabDto, ExtHostBrowsersShape } from '../../common/extHost.protocol.js'; +import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; +import { INativeWorkbenchEnvironmentService } from '../../../services/environment/electron-browser/environmentService.js'; +import { workbenchInstantiationService } from '../../../test/browser/workbenchTestServices.js'; +import { TestWorkspaceTrustEnablementService, TestWorkspaceTrustManagementService } from '../../../test/common/workbenchTestServices.js'; +import { BrowserViewSharingState, IBrowserViewCDPService, IBrowserViewWorkbenchService } from '../../../contrib/browserView/common/browserView.js'; +import { IBrowserZoomService } from '../../../contrib/browserView/common/browserZoomService.js'; +import { BrowserViewWorkbenchService } from '../../../contrib/browserView/electron-browser/browserViewWorkbenchService.js'; + +suite('External native browser presentation', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const resource = URI.parse('test-canvas:/authority/session/chat/instance'); + + test('coalesces creation without registering a BrowserEditorInput or extension API/CDP target', async () => { + const fixture = createFixture(store); + const first = fixture.service.getOrCreateExternalBrowserView('canvas', resource, 'http://localhost:41000/'); + assert.throws(() => fixture.service.getOrCreateLazy({ id: 'canvas' }), /external presentation/); + const second = fixture.service.getOrCreateExternalBrowserView('canvas', resource, 'http://localhost:41000/'); + const [model, repeated] = await Promise.all([first, second]); + await assert.rejects(fixture.api.$startCDPSession('api-session', model.id), /Unknown browser id/); + await assert.rejects(model.setSharedWithAgent(true), /cannot be shared/); + assert.deepStrictEqual({ + sameModel: model === repeated, + creations: fixture.creations.length, + known: [...fixture.service.getKnownBrowserViews().keys()], + published: fixture.published, + cdpTargets: fixture.cdpTargets, + sharing: model.sharingState, + options: fixture.creations[0].options, + }, { + sameModel: true, creations: 1, known: [], published: [], cdpTargets: [], sharing: BrowserViewSharingState.Unavailable, + options: { + presentation: { type: 'external', resource }, + host: { windowId: 1 }, owner: { type: 'user' }, initialAudiences: [], + session: { scope: BrowserViewStorageScope.Agent, affinity: `external:${resource.toString()}` }, + initialUrl: 'http://localhost:41000/', + }, + }); + }); + + test('enumeration after renderer startup excludes external pages but preserves ordinary browser APIs', async () => { + const ordinary = createViewInfo('ordinary', { host: { windowId: 1 }, owner: { type: 'user' }, session: { scope: BrowserViewStorageScope.Global }, initialUrl: 'http://localhost:41000/normal' }); + const external = { ...ordinary, id: 'canvas', presentation: { type: 'external' as const, resource } }; + const fixture = createFixture(store, [external, ordinary]); + await timeout(0); + const input = fixture.service.getKnownBrowserViews().get('ordinary'); + assert.ok(input); + store.add(input); + await fixture.api.$startCDPSession('api-session', input.id); + await assert.rejects(fixture.api.$startCDPSession('canvas-api-session', external.id), /Unknown browser id/); + assert.deepStrictEqual({ + known: [...fixture.service.getKnownBrowserViews().keys()], + published: fixture.published.map(tab => ({ id: tab.id, url: tab.url })), + cdpTargets: fixture.cdpTargets, + creations: fixture.creations.length, + }, { + known: ['ordinary'], published: [{ id: 'ordinary', url: 'http://localhost:41000/normal' }], + cdpTargets: ['ordinary'], creations: 0, + }); + }); + + test('reads semantics through a user-scoped native call without an API session or sharing', async () => { + const fixture = createFixture(store); + const model = await fixture.service.getOrCreateExternalBrowserView('canvas', resource, 'http://localhost:41000/'); + const snapshot = await model.getAccessibilitySnapshot(); + assert.deepStrictEqual({ + snapshot, semanticCalls: fixture.semanticCalls, cdpTargets: fixture.cdpTargets, + published: fixture.published, sharing: model.sharingState, + }, { + snapshot: { scope: 'main-frame', text: 'button: Increment', truncated: false }, + semanticCalls: [{ id: 'canvas', hostWindowId: 1 }], cdpTargets: [], published: [], sharing: BrowserViewSharingState.Unavailable, + }); + }); +}); + +function createFixture(store: Pick, initial: IBrowserViewInfo[] = []) { + const instantiationService = workbenchInstantiationService(undefined, store); + const views = new Map(initial.map(info => [info.id, info])); + const creations: { id: string; options: IBrowserViewCreateOptions }[] = []; + const published: BrowserTabDto[] = []; + const cdpTargets: string[] = []; + const semanticCalls: { id: string; hostWindowId: number }[] = []; + const onDidCreate = store.add(new Emitter()); + const native = new class extends mock() { + override readonly onDidCreateBrowserView = onDidCreate.event; + override async getBrowserViews() { return [...views.values()]; } + override async updateWindowConfiguration() { } + override async getOrCreateBrowserView(id: string, options: IBrowserViewCreateOptions) { + const existing = views.get(id); + if (existing) { + validateBrowserViewReuse(existing, options); + return existing; + } + creations.push({ id, options }); + const info = createViewInfo(id, options); + views.set(id, info); + onDidCreate.fire({ info }); + return info; + } + override async destroyBrowserView(id: string) { views.delete(id); } + override async setBrowserZoomIndex() { } + override async getAccessibilitySnapshot(id: string, hostWindowId: number) { + semanticCalls.push({ id, hostWindowId }); + return { scope: 'main-frame' as const, text: 'button: Increment', truncated: false }; + } + override onDynamicDidClose() { return Event.None; } + override onDynamicDidNavigate() { return Event.None; } + override onDynamicDidChangePermissions() { return Event.None; } + override onDynamicDidChangeLoadingState() { return Event.None; } + override onDynamicDidChangeDevToolsState() { return Event.None; } + override onDynamicDidChangeTitle() { return Event.None; } + override onDynamicDidChangeFavicon() { return Event.None; } + override onDynamicDidChangeOwner() { return Event.None; } + override onDynamicDidChangeFocus() { return Event.None; } + override onDynamicDidChangeVisibility() { return Event.None; } + override onDynamicDidChangeDeviceEmulation() { return Event.None; } + override onDynamicDidChangeElementSelectionState() { return Event.None; } + override onDynamicDidChangeAreaSelectionActive() { return Event.None; } + override onDynamicDidChangeAudiences() { return Event.None; } + override onDynamicDidChangeRemoteStatus() { return Event.None; } + }(); + const server = ProxyChannel.fromService(native, store.add(new DisposableStore())); + const channel: IChannel = { + call: (command, arg, token) => server.call(undefined, command, arg, token), + listen: (event, arg) => server.listen(undefined, event, arg), + }; + instantiationService.stub(IMainProcessService, { getChannel: () => channel }); + instantiationService.stub(INativeWorkbenchEnvironmentService, { userHome: URI.file('/test/browser-host-home') }); + instantiationService.stub(IWorkspaceTrustEnablementService, new TestWorkspaceTrustEnablementService()); + instantiationService.stub(IWorkspaceTrustManagementService, store.add(new class extends TestWorkspaceTrustManagementService { + override getTrustedUris(): URI[] { return []; } + }())); + instantiationService.stub(IAgentNetworkFilterService, { isEnabled: () => false, isUriAllowed: () => true, onDidChange: Event.None }); + instantiationService.stub(IBrowserZoomService, { getEffectiveZoomIndex: () => browserZoomDefaultIndex, onDidChangeZoom: Event.None }); + instantiationService.stub(IBrowserViewCDPService, new class extends mock() { + override async createSessionGroup(id: string) { cdpTargets.push(id); return 'cdp-group'; } + override async destroySessionGroup() { } + override onCDPMessage() { return Event.None; } + override onDidDestroy() { return Event.None; } + }); + const service = store.add(instantiationService.createInstance(BrowserViewWorkbenchService)); + instantiationService.stub(IBrowserViewWorkbenchService, service); + const proxy = new class extends mock() { + override $onDidOpenBrowserTab(tab: BrowserTabDto) { published.push(tab); } + override $onDidCloseBrowserTab() { } + override $onDidChangeBrowserTabState() { } + override $onDidChangeActiveBrowserTab() { } + override $onCDPSessionClosed() { } + }(); + const api = store.add(instantiationService.createInstance(MainThreadBrowsers, SingleProxyRPCProtocol(proxy))); + return { service, api, views, creations, published, cdpTargets, semanticCalls }; +} + +function createViewInfo(id: string, options: IBrowserViewCreateOptions): IBrowserViewInfo { + return { + id, host: options.host, owner: options.owner, presentation: options.presentation, + state: { + url: options.initialUrl ?? '', title: '', canGoBack: false, canGoForward: false, loading: false, + focused: false, visible: false, isDevToolsOpen: false, lastScreenshot: undefined, lastFavicon: undefined, + lastError: undefined, certificateError: undefined, + storageScope: typeof options.session === 'string' ? BrowserViewStorageScope.Ephemeral : options.session.scope, + storageKeys: {}, permissions: { origins: {} }, browserZoomIndex: browserZoomDefaultIndex, + elementSelectionState: { active: false, options: {} }, isRemoteSession: false, + isAreaSelectionActive: false, device: undefined, audiences: [], + }, + }; +} diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts index 71a01e7d4a6e8..9f07aecb47eaf 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibility.contribution.ts @@ -25,11 +25,12 @@ registerSingleton(IAccessibleViewService, AccessibleViewService, InstantiationTy registerSingleton(IAccessibleViewInformationService, AccessibleViewInformationService, InstantiationType.Delayed); const workbenchRegistry = Registry.as(WorkbenchExtensions.Workbench); -workbenchRegistry.registerWorkbenchContribution(EditorAccessibilityHelpContribution, LifecyclePhase.Eventually); +// Register command handlers before an interactive surface can receive its first accessibility shortcut. +registerWorkbenchContribution2(EditorAccessibilityHelpContribution.ID, EditorAccessibilityHelpContribution, WorkbenchPhase.BlockRestore); workbenchRegistry.registerWorkbenchContribution(UnfocusedViewDimmingContribution, LifecyclePhase.Restored); -workbenchRegistry.registerWorkbenchContribution(AccesibleViewHelpContribution, LifecyclePhase.Eventually); -workbenchRegistry.registerWorkbenchContribution(AccesibleViewContributions, LifecyclePhase.Eventually); +registerWorkbenchContribution2(AccesibleViewHelpContribution.ID, AccesibleViewHelpContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(AccesibleViewContributions.ID, AccesibleViewContributions, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(AccessibilityStatus.ID, AccessibilityStatus, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ExtensionAccessibilityHelpDialogContribution.ID, ExtensionAccessibilityHelpDialogContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index 39b07521d6015..46705bc2e0186 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -75,6 +75,7 @@ export const enum AccessibilityVerbositySettingId { Survey = 'accessibility.verbosity.survey', Automations = 'accessibility.verbosity.automations', BrowserElementCommenting = 'accessibility.verbosity.browserElementCommenting', + SessionCanvas = 'accessibility.verbosity.sessionCanvas', ChatPetAchievements = 'accessibility.verbosity.chatPetAchievements' } @@ -241,6 +242,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.browserElementCommenting', 'Provide information about how to access element commenting accessibility help in the Integrated Browser.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.SessionCanvas]: { + description: localize('verbosity.sessionCanvas', "Provide accessibility help hints in session canvas views."), + ...baseVerbosityProperty, + }, [AccessibilityVerbositySettingId.ChatPetAchievements]: { description: localize('verbosity.chatPetAchievements', 'Provide information about how to access chat pet achievements accessibility help when the Achievements modal is focused.'), ...baseVerbosityProperty diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts index 1633c44821f77..1310e4a8cd6a8 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleView.ts @@ -91,6 +91,8 @@ export class AccessibleView extends Disposable { private _title: HTMLElement; private readonly _toolbar: WorkbenchToolBar; private readonly _toolbarMenu = this._register(new MutableDisposable()); + private readonly _renderDisposables = this._register(new MutableDisposable()); + private readonly _viewDisposables = this._register(new DisposableStore()); private _currentProvider: AccesibleViewContentProvider | undefined; private _currentContent: string | undefined; @@ -309,9 +311,11 @@ export class AccessibleView extends Disposable { return this._render(provider, container, showAccessibleViewHelp); }, onHide: () => { + this._renderDisposables.clear(); + this._viewDisposables.clear(); + this._viewContainer = undefined; this._toolbarMenu.clear(); if (!showAccessibleViewHelp) { - this._updateLastProvider(); // Save cursor position before disposing so it can be restored on reopen if (this._currentProvider) { const currentPosition = this._editorWidget.getPosition(); @@ -339,7 +343,7 @@ export class AccessibleView extends Disposable { this.showSymbol(this._currentProvider, symbol); } if (provider instanceof AccessibleContentProvider && provider.onDidRequestClearLastProvider) { - this._register(provider.onDidRequestClearLastProvider((id: string) => { + this._viewDisposables.add(provider.onDidRequestClearLastProvider((id: string) => { if (this._lastProvider?.options.id === id) { this._lastProvider = undefined; } @@ -351,13 +355,13 @@ export class AccessibleView extends Disposable { this._lastProvider = provider; } if (provider.id === AccessibleViewProviderId.PanelChat || provider.id === AccessibleViewProviderId.QuickChat) { - this._register(this._codeBlockContextProviderService.registerProvider({ getCodeBlockContext: () => this.getCodeBlockContext() }, 'accessibleView')); + this._viewDisposables.add(this._codeBlockContextProviderService.registerProvider({ getCodeBlockContext: () => this.getCodeBlockContext() }, 'accessibleView')); } if (provider instanceof ExtensionContentProvider) { this._storageService.store(`${ACCESSIBLE_VIEW_SHOWN_STORAGE_PREFIX}${provider.id}`, true, StorageScope.APPLICATION, StorageTarget.USER); } if (provider.onDidChangeContent) { - this._register(provider.onDidChangeContent(() => { + this._viewDisposables.add(provider.onDidChangeContent(() => { if (this._viewContainer) { this._render(provider, this._viewContainer, showAccessibleViewHelp); } })); } @@ -600,6 +604,8 @@ export class AccessibleView extends Disposable { } private _render(provider: AccesibleViewContentProvider, container: HTMLElement, showAccessibleViewHelp?: boolean, updatedContent?: string): IDisposable { + const disposableStore = new DisposableStore(); + this._renderDisposables.value = disposableStore; const isSameProvider = this._currentProvider?.id === provider.id; const previousPosition = isSameProvider ? this._editorWidget.getPosition() : undefined; const previousScrollTop = isSameProvider ? this._editorWidget.getScrollTop() : undefined; @@ -612,7 +618,7 @@ export class AccessibleView extends Disposable { const widgetIsFocused = this._editorWidget.hasTextFocus() || this._editorWidget.hasWidgetFocus(); const stableUri = this._getStableUri(provider.id); this._getTextModel(stableUri).then((model) => { - if (!model) { + if (!model || disposableStore.isDisposed || this._currentProvider !== provider) { return; } // Update the content of the existing model instead of creating a new one @@ -700,6 +706,7 @@ export class AccessibleView extends Disposable { e?.stopPropagation(); return; } + this._renderDisposables.clear(); if (!this._isInQuickPick) { provider.onClose(); } @@ -719,7 +726,6 @@ export class AccessibleView extends Disposable { this._currentProvider?.dispose(); this._currentProvider = undefined; }; - const disposableStore = new DisposableStore(); disposableStore.add(this._editorWidget.onKeyDown((e) => { if (e.keyCode === KeyCode.Enter) { this._commandService.executeCommand('editor.action.openLink'); diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts index b1be7884e4136..d1164ba9647cc 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibleViewContributions.ts @@ -3,15 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, type IDisposable } from '../../../../base/common/lifecycle.js'; import { accessibleViewIsShown } from './accessibilityConfiguration.js'; import { AccessibilityHelpAction, AccessibleViewAction } from './accessibleViewActions.js'; import { AccessibleViewType, AccessibleContentProvider, ExtensionContentProvider, IAccessibleViewService } from '../../../../platform/accessibility/browser/accessibleView.js'; -import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { AccessibleViewRegistry, type IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; export class AccesibleViewHelpContribution extends Disposable { - static ID: 'accesibleViewHelpContribution'; + static readonly ID = 'accesibleViewHelpContribution'; constructor() { super(); this._register(AccessibilityHelpAction.addImplementation(115, 'accessible-view-help', accessor => { @@ -22,10 +22,26 @@ export class AccesibleViewHelpContribution extends Disposable { } export class AccesibleViewContributions extends Disposable { - static ID: 'accesibleViewContributions'; + static readonly ID = 'accesibleViewContributions'; + private readonly _implementations = this._register(new DisposableMap()); + constructor() { super(); - AccessibleViewRegistry.getImplementations().forEach(impl => { + this._register(AccessibleViewRegistry.onDidChange(() => this._updateImplementations())); + this._updateImplementations(); + } + + private _updateImplementations(): void { + const implementations = new Set(AccessibleViewRegistry.getImplementations()); + for (const implementation of this._implementations.keys()) { + if (!implementations.has(implementation)) { + this._implementations.deleteAndDispose(implementation); + } + } + for (const impl of implementations) { + if (this._implementations.has(impl)) { + continue; + } const implementation = (accessor: ServicesAccessor) => { const provider: AccessibleContentProvider | ExtensionContentProvider | undefined = impl.getProvider(accessor); if (!provider) { @@ -40,10 +56,10 @@ export class AccesibleViewContributions extends Disposable { } }; if (impl.type === AccessibleViewType.View) { - this._register(AccessibleViewAction.addImplementation(impl.priority, impl.name, implementation, impl.when)); + this._implementations.set(impl, AccessibleViewAction.addImplementation(impl.priority, impl.name, implementation, impl.when)); } else { - this._register(AccessibilityHelpAction.addImplementation(impl.priority, impl.name, implementation, impl.when)); + this._implementations.set(impl, AccessibilityHelpAction.addImplementation(impl.priority, impl.name, implementation, impl.when)); } - }); + } } } diff --git a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts index fc42daa8d7cb2..038c4e667e741 100644 --- a/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/accessibility/browser/editorAccessibilityHelp.ts @@ -25,7 +25,7 @@ import { IAccessibilityService } from '../../../../platform/accessibility/common import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class EditorAccessibilityHelpContribution extends Disposable { - static ID: 'editorAccessibilityHelpContribution'; + static readonly ID = 'editorAccessibilityHelpContribution'; constructor() { super(); this._register(AccessibilityHelpAction.addImplementation(90, 'editor', async accessor => { diff --git a/src/vs/workbench/contrib/accessibility/test/browser/accessibleView.test.ts b/src/vs/workbench/contrib/accessibility/test/browser/accessibleView.test.ts index 49949d3fb9114..b93bba89d7cd1 100644 --- a/src/vs/workbench/contrib/accessibility/test/browser/accessibleView.test.ts +++ b/src/vs/workbench/contrib/accessibility/test/browser/accessibleView.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Event } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../../platform/accessibility/browser/accessibleView.js'; @@ -42,18 +42,7 @@ suite('AccessibleView', () => { test('disposes the toolbar menu when the context view hides', () => { let disposeCount = 0; - let delegate: IContextViewDelegate | undefined; - const contextViewService = new class extends mock() { - override showContextView(contextViewDelegate: IContextViewDelegate): IOpenContextView { - delegate = contextViewDelegate; - return { close: () => this.hideContextView() }; - } - - override hideContextView(): void { - delegate?.onHide?.(); - delegate = undefined; - } - }; + const contextViewService = createContextViewService(); const instantiationService = workbenchInstantiationService({}, disposables); instantiationService.stub(IContextViewService, contextViewService); instantiationService.stub(IMenuService, new class extends mock() { @@ -87,4 +76,40 @@ suite('AccessibleView', () => { accessibleView.dispose(); assert.strictEqual(disposeCount, 1); }); + + test('releases live content subscriptions when the context view closes', () => { + const changes = disposables.add(new Emitter()); + const contextViewService = createContextViewService(); + const instantiationService = workbenchInstantiationService({}, disposables); + instantiationService.stub(IContextViewService, contextViewService); + const accessibleView = disposables.add(instantiationService.createInstance(AccessibleView)); + const provider = disposables.add(new AccessibleContentProvider( + AccessibleViewProviderId.Editor, + { type: AccessibleViewType.View }, + () => 'updated user content', + () => { }, + 'test.verbosity', + undefined, undefined, undefined, undefined, + changes.event, + )); + accessibleView.show(provider); + const open = changes.hasListeners(); + contextViewService.hideContextView(); + assert.deepStrictEqual({ open, closed: changes.hasListeners() }, { open: true, closed: false }); + }); }); + +function createContextViewService(): IContextViewService { + let delegate: IContextViewDelegate | undefined; + return new class extends mock() { + override showContextView(contextViewDelegate: IContextViewDelegate): IOpenContextView { + delegate = contextViewDelegate; + return { close: () => this.hideContextView() }; + } + + override hideContextView(): void { + delegate?.onHide?.(); + delegate = undefined; + } + }; +} diff --git a/src/vs/workbench/contrib/accessibility/test/browser/accessibleViewContributions.test.ts b/src/vs/workbench/contrib/accessibility/test/browser/accessibleViewContributions.test.ts new file mode 100644 index 0000000000000..6fbf40c41508c --- /dev/null +++ b/src/vs/workbench/contrib/accessibility/test/browser/accessibleViewContributions.test.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType, IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { AccessibleViewRegistry, type IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { AccessibilityHelpAction, AccessibleViewAction } from '../../browser/accessibleViewActions.js'; +import { AccesibleViewContributions } from '../../browser/accessibleViewContributions.js'; + +suite('Accessible View contribution registration', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + for (const [type, command] of [[AccessibleViewType.Help, AccessibilityHelpAction], [AccessibleViewType.View, AccessibleViewAction]] as const) { + test(`${type} follows late registration, removal and contribution disposal`, async () => { + const shown: string[] = []; + const instantiationService = workbenchInstantiationService({}, store); + instantiationService.stub(IAccessibleViewService, new class extends mock() { + override show(): void { shown.push('late'); } + }); + store.add(command.addImplementation(Number.MAX_SAFE_INTEGER - 1, 'test-fallback', () => { + shown.push('fallback'); + return true; + })); + const contribution = store.add(new AccesibleViewContributions()); + const implementation: IAccessibleViewImplementation = { + type, name: 'test-late', priority: Number.MAX_SAFE_INTEGER, + getProvider: () => store.add(new AccessibleContentProvider( + AccessibleViewProviderId.SessionCanvas, { type }, () => 'Canvas content', () => { }, 'test.verbosity', + )), + }; + const invoke = () => instantiationService.invokeFunction(accessor => command.runCommand(accessor, undefined)); + + await invoke(); + const registration = store.add(AccessibleViewRegistry.register(implementation)); + await invoke(); + registration.dispose(); + await invoke(); + store.add(AccessibleViewRegistry.register(implementation)); + await invoke(); + contribution.dispose(); + await invoke(); + + assert.deepStrictEqual(shown, ['fallback', 'late', 'fallback', 'late', 'fallback']); + }); + } +}); diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts index d9b8548260b5e..5d01d4d95e5e0 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -12,6 +12,7 @@ import { IBrowserViewEditorOpenOptions } from '../../../../platform/browserView/ import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; import { ITunnelProxyInfo } from '../../../../platform/tunnel/common/tunnelProxy.js'; import { BrowserEditorInput, IBrowserEditorInputData } from '../common/browserEditorInput.js'; +import { URI } from '../../../../base/common/uri.js'; class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { declare readonly _serviceBrand: undefined; @@ -52,6 +53,10 @@ class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { throw new Error('Integrated Browser is not available in web.'); } + async getOrCreateExternalBrowserView(_id: string, _resource: URI, _initialUrl: string): Promise { + throw new Error('Integrated Browser is not available in web.'); + } + getOrCreateLazy(_data: IBrowserEditorInputData): BrowserEditorInput { throw new Error('Integrated Browser is not available in web.'); } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 232e8b3f21386..b85b31557f7ba 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -59,6 +59,8 @@ import { IBrowserElementSelectionState, isBrowserViewStorageScopeShareableWithAgent, IBrowserViewHost, + IBrowserViewExternalPresentation, + IBrowserViewAccessibilitySnapshot, } from '../../../../platform/browserView/common/browserView.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDomains.js'; @@ -332,6 +334,9 @@ export interface IBrowserViewWorkbenchService { /** Creates and resolves a browser view, optionally requesting editor presentation. */ createBrowserView(options: IBrowserViewWorkbenchCreateOptions, editorOpenOptions?: IBrowserViewEditorOpenOptions): Promise; + /** An externally owned model, never registered as an ordinary browser editor or API tab. */ + getOrCreateExternalBrowserView(id: string, resource: URI, initialUrl: string): Promise; + /** * Get an existing browser view for the given ID, or create a new one if it doesn't exist. * The underlying browser view is not created until the editor is opened or the model is resolved. @@ -386,6 +391,7 @@ export interface IBrowserViewCDPService { */ export interface IBrowserViewModel extends IDisposable { readonly id: string; + readonly presentation?: IBrowserViewExternalPresentation; readonly host: IBrowserViewHost; readonly owner: IBrowserViewOwner; readonly associatedResource: URI | undefined; @@ -446,6 +452,7 @@ export interface IBrowserViewModel extends IDisposable { reload(hard?: boolean): Promise; toggleDevTools(): Promise; captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise; + getAccessibilitySnapshot(): Promise; focus(force?: boolean): Promise; findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise; stopFindInPage(keepSelection?: boolean): Promise; @@ -518,6 +525,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { readonly associatedResource: URI | undefined, initialState: IBrowserViewState, private readonly browserViewService: IBrowserViewService, + readonly presentation: IBrowserViewExternalPresentation | undefined = undefined, @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IDialogService private readonly dialogService: IDialogService, @@ -694,7 +702,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { get storageScope(): BrowserViewStorageScope { return this._storageScope; } get isRemoteSession(): boolean { return this._isRemoteSession; } get sharingState(): BrowserViewSharingState { - if (!this.browserViewWorkbenchService.isSharingAvailable) { + if (this.presentation || !this.browserViewWorkbenchService.isSharingAvailable) { return BrowserViewSharingState.Unavailable; } if (this._audiences.some(audience => audience.type === 'agent')) { @@ -706,7 +714,7 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return BrowserViewSharingState.Available; } get isDirectlyShareable(): boolean { - return isBrowserViewStorageScopeShareableWithAgent(this.storageScope, this.agentNetworkFilterService.isEnabled()); + return !this.presentation && isBrowserViewStorageScopeShareableWithAgent(this.storageScope, this.agentNetworkFilterService.isEnabled()); } get zoomFactor(): number { return browserZoomFactors[this._browserZoomIndex]; } get canZoomIn(): boolean { return this._browserZoomIndex < browserZoomFactors.length - 1; } @@ -819,6 +827,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return result; } + async getAccessibilitySnapshot(): Promise { + return this.browserViewService.getAccessibilitySnapshot(this.id, this.host.windowId); + } + async focus(force?: boolean): Promise { return this.browserViewService.focus(this.id, force); } @@ -967,6 +979,9 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { private static readonly SHARE_DONT_ASK_KEY = 'browserView.shareWithAgent.dontAskAgain'; async setSharedWithAgent(shared: boolean): Promise { + if (shared && this.presentation) { + throw new Error('Externally presented pages cannot be shared through browser tools.'); + } if (!shared) { await this.browserViewService.setAudience(this.id, { type: 'agent' }, false); return this; @@ -1095,6 +1110,9 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { } override dispose(): void { + if (this._store.isDisposed) { + return; + } this._onWillDispose.fire(); // Clean up the browser view when the model is disposed diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserCanvasTheme.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserCanvasTheme.ts new file mode 100644 index 0000000000000..e23ce01206bec --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserCanvasTheme.ts @@ -0,0 +1,222 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color } from '../../../../base/common/color.js'; +import type { IBrowserCanvasTheme } from '../../../../platform/browserView/common/browserView.js'; +import { isDark } from '../../../../platform/theme/common/theme.js'; +import type { IColorTheme } from '../../../../platform/theme/common/themeService.js'; + +/** + * Maps the canvas guest's semantic theme contract to the owning VS Code theme. + * These are guest defaults, not inline declarations: an extension's own CSS wins. + */ +export function createBrowserCanvasTheme(theme: IColorTheme, font: string): IBrowserCanvasTheme { + const dark = isDark(theme.type); + const color = (...ids: string[]): Color => { + for (const id of ids) { + const value = theme.getColor(id); + if (value) { + return value; + } + } + return dark ? Color.white : Color.black; + }; + const cssVariables: Record = {}; + const set = (name: string, value: string | Color) => cssVariables[`--${name}`] = value.toString(); + const foreground = color('editor.foreground', 'foreground'); + const background = color('editor.background'); + const muted = color('sideBar.background', 'editor.background'); + const border = color('contrastBorder', 'panel.border', 'widget.border', 'foreground'); + const focus = color('focusBorder', 'textLink.foreground'); + const disabled = color('disabledForeground', 'descriptionForeground'); + const textMuted = color('descriptionForeground', 'foreground'); + const selection = color('editor.selectionBackground', 'list.activeSelectionBackground'); + const input = color('input.background', 'editor.background'); + const checked = color('inputOption.activeBackground', 'button.background'); + const semantic: Record = { + accent: color('textLink.foreground', 'focusBorder'), + attention: color('editorWarning.foreground', 'terminal.ansiYellow'), + danger: color('errorForeground', 'editorError.foreground'), + success: color('terminal.ansiGreen', 'charts.green'), + severe: color('terminal.ansiBrightYellow', 'editorWarning.foreground'), + done: color('terminal.ansiMagenta', 'charts.purple'), + sponsors: color('terminal.ansiMagenta', 'charts.purple'), + upsell: color('textLink.foreground', 'focusBorder'), + open: color('terminal.ansiGreen', 'charts.green'), + closed: color('errorForeground', 'editorError.foreground'), + neutral: textMuted, + }; + for (const [name, value] of Object.entries(semantic)) { + set(`background-color-${name}-emphasis`, value); + set(`background-color-${name}-muted`, value.transparent(0.16)); + set(`text-color-${name}`, value); + } + for (const name of ['accent', 'attention', 'danger', 'success']) { + set(`border-color-${name}-emphasis`, semantic[name]); + set(`border-color-${name}-muted`, semantic[name].transparent(0.4)); + } + set('border-color-closed-emphasis', semantic.closed); + for (const [name, value] of Object.entries({ + default: background, muted, overlay: color('editorWidget.background', 'editor.background'), + 'overlay-backdrop': Color.black.transparent(0.4), emphasis: foreground, + disabled: input, skeleton: muted, black: Color.black, white: Color.white, transparent: Color.transparent, + 'segmentedControl-bg-emphasis': selection, 'segmentedControl-bg-rest': input, 'segmentedControl-button-bg-rest': muted, + })) { + set(`background-color-${name}`, value); + } + for (const [name, value] of Object.entries({ + default: foreground, muted: textMuted, disabled, draft: textMuted, white: Color.white, + 'on-emphasis': color('button.foreground', 'editor.background'), link: semantic.accent, + })) { + set(`text-color-${name}`, value); + } + for (const name of ['default', 'emphasis', 'muted', 'overlay', 'pure', 'skeleton', 'subtle', 'subtle-opaque', 'subtle-opaque-input']) { + set(`border-color-${name}`, border); + } + set('border-color-transparent', Color.transparent); + set('color-focus-outline', focus); + set('color-white', Color.white); + set('outline-color-default', border); + set('outline-color-focus-default', focus); + for (const name of ['accent-emphasis', 'attention-emphasis', 'danger-emphasis', 'emphasis', 'rest', 'success-emphasis']) { + set(`outline-color-borderColor-${name}`, semantic[name.split('-')[0]] ?? border); + } + for (const family of ['default', 'primary', 'danger', 'invisible', 'outline']) { + for (const state of ['rest', 'hover', 'active', 'disabled']) { + const hover = state === 'hover' || state === 'active'; + const primary = family === 'primary'; + const invisible = family === 'invisible' || family === 'outline'; + set(`background-color-button-${family}-${state}`, invisible + ? hover ? color('toolbar.hoverBackground', 'list.hoverBackground') : Color.transparent + : family === 'danger' ? semantic.danger.transparent(hover ? 0.3 : 0.15) + : primary ? color(hover ? 'button.hoverBackground' : 'button.background') + : color(hover ? 'button.secondaryHoverBackground' : 'button.secondaryBackground', 'input.background')); + if (!primary || !hover) { + set(`text-color-button-${family}-${state}`, state === 'disabled' ? disabled + : family === 'danger' ? semantic.danger + : primary ? color('button.foreground') : color('button.secondaryForeground', 'foreground')); + } + if (family !== 'outline' && !(family === 'danger' && state === 'disabled') + && !(family === 'invisible' && (state === 'active' || state === 'disabled'))) { + set(`border-color-button-${family}-${state}`, family === 'danger' ? semantic.danger : invisible ? Color.transparent : border); + } + } + } + set('text-color-button-star', semantic.attention); + for (const state of ['rest', 'hover', 'active', 'disabled', 'selected']) { + const hover = state === 'hover' || state === 'active'; + set(`background-color-control-${state}`, state === 'selected' ? selection : hover ? color('list.hoverBackground', 'input.background') : input); + set(`background-color-control-transparent-${state}`, state === 'selected' ? selection : hover ? color('toolbar.hoverBackground', 'input.background') : Color.transparent); + if (state !== 'disabled' && state !== 'selected') { + set(`border-color-control-transparent-${state}`, state === 'rest' ? Color.transparent : focus); + } + if (state !== 'selected') { + set(`background-color-control-checked-${state}`, checked); + set(`border-color-control-checked-${state}`, focus); + } + if (!hover) { + set(`border-color-control-${state}`, state === 'selected' ? focus : border); + } + } + for (const name of ['rest', 'hover']) { + set(`text-color-control-danger-${name}`, semantic.danger); + } + for (const name of ['active', 'hover']) { + set(`background-color-control-danger-${name}`, semantic.danger.transparent(0.16)); + } + for (const [name, value] of Object.entries({ + danger: semantic.danger, emphasis: border, success: semantic.success, warning: semantic.attention, + })) { + set(`border-color-control-${name}`, value); + } + for (const [name, value] of Object.entries({ + 'checked-disabled': disabled, 'checked-rest': color('inputOption.activeForeground', 'foreground'), + disabled, icon: foreground, placeholder: color('input.placeholderForeground', 'descriptionForeground'), + rest: color('input.foreground', 'foreground'), + })) { + set(`text-color-control-${name}`, value); + } + for (const [name, value] of Object.entries({ + add: color('diffEditor.insertedTextBackground', 'diffEditor.insertedLineBackground'), + addLine: color('diffEditor.insertedLineBackground', 'diffEditor.insertedTextBackground'), + addLineHover: color('diffEditor.insertedLineBackground', 'diffEditor.insertedTextBackground'), + additionNum: semantic.success.transparent(0.16), + del: color('diffEditor.removedTextBackground', 'diffEditor.removedLineBackground'), + delLine: color('diffEditor.removedLineBackground', 'diffEditor.removedTextBackground'), + delLineHover: color('diffEditor.removedLineBackground', 'diffEditor.removedTextBackground'), + deletionNum: semantic.danger.transparent(0.16), + hunkLine: color('diffEditor.unchangedRegionBackground', 'editor.background'), + hunkNum: muted, hunkNumHover: muted, normal: background, normalNum: background, + })) { + set(`background-color-diffBlob-${name}`, value); + } + set('text-color-diffBlob-addSign', semantic.success); + set('text-color-diffBlob-delSign', semantic.danger); + set('text-color-diffBlob-lineNum', color('editorLineNumber.foreground', 'descriptionForeground')); + const palette: Record = { + auburn: color('terminal.ansiRed'), blue: color('charts.blue', 'terminal.ansiBlue'), + brown: color('terminal.ansiYellow'), coral: color('terminal.ansiBrightRed'), cyan: color('terminal.ansiCyan'), + gray: textMuted, green: semantic.success, indigo: color('terminal.ansiBlue'), lemon: color('terminal.ansiBrightYellow'), + lime: color('terminal.ansiBrightGreen'), olive: color('terminal.ansiGreen'), orange: color('charts.orange', 'terminal.ansiYellow'), + pine: color('terminal.ansiGreen'), pink: color('terminal.ansiBrightMagenta'), plum: color('terminal.ansiMagenta'), + purple: color('charts.purple', 'terminal.ansiMagenta'), red: semantic.danger, + teal: color('terminal.ansiCyan'), yellow: semantic.attention, + }; + for (const [name, value] of Object.entries(palette)) { + set(`background-color-label-${name}-rest`, value.transparent(0.16)); + set(`text-color-label-${name}-rest`, value); + if (name !== 'cyan' && name !== 'indigo') { + set(`color-data-${name}-emphasis`, value); + set(`color-data-${name}-muted`, value.transparent(0.4)); + } + } + for (const name of ['red', 'orange', 'yellow', 'lime', 'green', 'teal', 'cyan', 'blue', 'violet', 'magenta', 'pink']) { + const value = palette[name] ?? palette.purple; + set(`true-color-${name}`, value); + set(`true-color-${name}-muted`, value.transparent(0.4)); + } + const syntax: Record = { + comment: 'comment', constant: 'number', entity: 'function', invalid: 'invalid', + keyword: 'keyword', regexp: 'regexp', string: 'string', tag: 'class', variable: 'variable', + }; + for (const [name, tokenType] of Object.entries(syntax)) { + const index = theme.getTokenStyleMetadata(tokenType, [], '')?.foreground; + const value = index === undefined ? undefined : theme.tokenColorMap[index]; + set(`syntax-color-${name}`, value && /^#[\da-f]{3,8}$/i.test(value) ? Color.fromHex(value) : foreground); + } + set('syntax-color-bg', background); + set('syntax-color-fg', foreground); + set('syntax-color-default', foreground); + set('text-selection-background', selection); + set('text-selection-foreground', color('editor.selectionForeground', 'editor.foreground')); + for (const name of ['sans', 'sans-display', 'system']) { + set(`font-${name}`, font); + } + set('font-mono', 'ui-monospace, SFMono-Regular, Consolas, monospace'); + for (const [name, weight] of Object.entries({ light: 300, normal: 400, medium: 500, semibold: 600 })) { + set(`font-weight-${name}`, String(weight)); + } + for (const [name, size] of Object.entries({ + display: 36, 'title-large': 28, 'title-medium': 24, 'title-small': 20, subtitle: 16, + 'body-large': 16, 'body-medium': 14, 'body-ui': 13, 'body-small': 12, caption: 11, badge: 11, 'code-block': 13, 'code-inline': 12, + })) { + set(`text-${name}`, `${size}px`); + if (name !== 'code-inline') { + set(`leading-${name}`, '1.5'); + } + } + const mode = dark ? 'dark' : 'light'; + return { + cssVariables, + attributes: { + 'data-color-mode': mode, 'data-dark-theme': 'dark', 'data-light-theme': 'light', + 'data-theme-source': 'vscode', 'data-theme-tone': mode, 'data-visual-mode': 'default', + }, + colorScheme: mode, + stylesheets: { + rampa: `:root { ${Object.entries(cssVariables).filter(([name]) => name.startsWith('--true-color-') || name.startsWith('--color-data-')).map(([name, value]) => `${name}: ${value};`).join(' ')} }`, + }, + }; +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 55bca97d538fc..e2d2c66737451 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -29,6 +29,7 @@ import { ILayoutService } from '../../../../platform/layout/browser/layoutServic import { IAction } from '../../../../base/common/actions.js'; import { IActionViewItem } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { focusWebContentsViewContainer } from './webContentsViewHost.js'; export const CONTEXT_BROWSER_FOCUSED = new RawContextKey('browserFocused', true, localize('browser.editorFocused', "Whether the browser editor is focused")); export const CONTEXT_BROWSER_HAS_URL = new RawContextKey('browserHasUrl', false, localize('browser.hasUrl', "Whether the browser has a URL loaded")); @@ -49,13 +50,6 @@ export enum BrowserActionGroup { Settings = '5_settings' } -/** - * Get the original implementation of HTMLElement focus (without window auto-focusing) - * before it gets overridden by the workbench. - */ -const originalHtmlElementFocus = HTMLElement.prototype.focus; - - /** * Base class for browser editor services that track the model lifecycle. * @@ -636,8 +630,7 @@ export class BrowserEditor extends EditorPane { * Make the browser container the active element without moving focus from the browser view. */ ensureBrowserFocus(): void { - originalHtmlElementFocus.call(this._browserContainer); - this.window.document.getSelection()?.removeAllRanges(); + focusWebContentsViewContainer(this._browserContainer); } /** diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserFileTrustWidget.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserFileTrustWidget.ts new file mode 100644 index 0000000000000..6abba10065b17 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserFileTrustWidget.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { dirname } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import type { IBrowserViewLoadError } from '../../../../platform/browserView/common/browserView.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { MANAGE_TRUST_COMMAND_ID } from '../../workspace/common/workspace.js'; +import type { IBrowserViewModel } from '../common/browserView.js'; + +/** Workspace Trust recovery for a native file navigation denied by main. */ +export class BrowserFileTrustWidget extends Disposable { + readonly element = $('.browser-error-container.browser-file-trust-container'); + private readonly contentStore = this._register(new DisposableStore()); + private model: IBrowserViewModel | undefined; + private error: IBrowserViewLoadError | undefined; + private url = ''; + private loading = false; + private generation = 0; + private buttons: Button[] = []; + + constructor( + @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, + @ICommandService private readonly commandService: ICommandService, + @INotificationService private readonly notificationService: INotificationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this.element.style.display = 'none'; + this.element.setAttribute('role', 'group'); + this.element.setAttribute('aria-label', localize('browser.fileAccessBlocked', "File Access Blocked")); + } + + update(model: IBrowserViewModel | undefined): void { + const error = model?.error?.fileAccessDenied ? model.error : undefined; + const url = model?.url ?? ''; + const loading = model?.loading ?? false; + if (model === this.model && error === this.error && url === this.url && loading === this.loading) { + return; + } + this.generation++; + this.contentStore.clear(); + this.buttons = []; + this.element.replaceChildren(); + this.model = model; + this.error = error; + this.url = url; + this.loading = loading; + this.element.style.display = 'none'; + if (!model || !error) { + return; + } + const resource = URI.parse(error.url); + if (resource.scheme !== Schemas.file) { + return; + } + const folder = dirname(resource.with({ query: null, fragment: null })); + const content = $('.browser-error-content'); + const title = $('.browser-error-title'); + title.textContent = localize('browser.fileAccessBlocked', "File Access Blocked"); + const detail = $('.browser-error-detail'); + detail.textContent = localize('browser.fileAccessBlockedDetail', "This local file is not in a trusted folder. Review Workspace Trust or leave it blocked."); + const path = $('.browser-error-detail'); + const pathValue = $('code'); + pathValue.textContent = folder.fsPath; + path.appendChild(pathValue); + content.append(title, detail, path); + if (model.presentation) { + const executionDetail = $('.browser-error-detail'); + executionDetail.textContent = localize('browser.fileAccessExecutionApproval', "Approving an extension to run does not grant local file access."); + content.appendChild(executionDetail); + } + const generation = this.generation; + const addButton = (label: string, secondary: boolean, action: () => Promise) => { + const container = $('.browser-error-detail'); + const button = this.contentStore.add(new Button(container, { ...defaultButtonStyles, secondary })); + button.label = label; + button.enabled = !loading; + this.buttons.push(button); + this.contentStore.add(button.onDidClick(() => void this.run(model, error, generation, action))); + content.appendChild(container); + }; + addButton(localize('browser.trustFileFolder', "Trust Folder..."), false, async () => { + const trusted = await this.workspaceTrustRequestService.requestResourcesTrust({ + uri: folder, + message: localize('browser.trustFileFolderMessage', "The integrated browser can load local files only from trusted folders. Trusting this folder applies to Workspace Trust throughout VS Code, not just this page."), + }); + if (trusted && this.isCurrent(model, error, generation) && !model.loading) { + await model.loadURL(error.url); + } + }); + addButton(localize('browser.manageFileTrust', "Manage Workspace Trust"), true, async () => { + await this.commandService.executeCommand(MANAGE_TRUST_COMMAND_ID); + }); + addButton(localize('browser.reloadTrustedFile', "Reload"), true, async () => { + await model.loadURL(error.url); + }); + this.element.appendChild(content); + this.element.style.display = ''; + } + + focus(): boolean { + const button = this.buttons.find(button => button.enabled); + if (!button) { + return false; + } + button.focus(); + return true; + } + + private isCurrent(model: IBrowserViewModel, error: IBrowserViewLoadError, generation: number): boolean { + return !this._store.isDisposed && this.model === model && this.error === error && this.generation === generation; + } + + private async run(model: IBrowserViewModel, error: IBrowserViewLoadError, generation: number, action: () => Promise): Promise { + if (!this.isCurrent(model, error, generation) || model.loading) { + return; + } + for (const button of this.buttons) { + button.enabled = false; + } + try { + await action(); + } catch (failure) { + this.logService.error('Browser file trust operation failed.', failure); + if (this.isCurrent(model, error, generation)) { + this.notificationService.error(localize('browser.fileTrustOperationFailed', "The file could not be reopened. Review Workspace Trust and try Reload again.")); + } + } finally { + if (this.isCurrent(model, error, generation)) { + for (const button of this.buttons) { + button.enabled = !model.loading; + } + } + } + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewPermissions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewPermissions.ts new file mode 100644 index 0000000000000..0acbd013c95ae --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewPermissions.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { assertNever } from '../../../../base/common/assert.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore, type IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import type { IBrowserViewDeviceRequest } from '../../../../platform/browserView/common/browserView.js'; +import { type BrowserDeviceType, PERMISSION_CATEGORY_DESCRIPTORS, type PermissionCategory, type PermissionDecision } from '../../../../platform/browserView/common/browserPermissions.js'; +import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IQuickInputService, type IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import type { IBrowserViewModel } from '../common/browserView.js'; + +/** Permission prompts and device choices owned by one attached native page. */ +export class BrowserViewPermissionHandler extends Disposable { + private readonly permissions = this._register(new DisposableMap()); + private readonly devices = this._register(new DisposableMap()); + private modelDisposed = false; + + constructor( + private readonly model: IBrowserViewModel, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IDialogService private readonly dialogService: IDialogService, + @INotificationService private readonly notificationService: INotificationService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this._register(model.onDidRequestPermission(event => { + if (event.device) { + this.requestDevice(event.origin, event.device); + } else { + void this.requestPermission(event.origin, event.category).catch(error => this.reportError(error)); + } + })); + const cancelRequests = () => { + this.permissions.clearAndDisposeAll(); + // Retain cancelled identities until native acknowledgements drain queued updates. + for (const picker of this.devices.values()) { + picker.dispose(); + } + }; + this._register(model.onWillNavigate(cancelRequests)); + this._register(model.onDidNavigate(cancelRequests)); + this._register(Event.once(model.onWillDispose)(() => { + // Native teardown settles requests without a write to the retiring page. + this.modelDisposed = true; + this.dispose(); + })); + } + + private requestDevice(origin: string, request: IBrowserViewDeviceRequest): void { + const existing = this.devices.get(request.requestId); + if (existing) { + existing.update(request); + return; + } + const picker = createDevicePicker(this.quickInputService, origin, request, deviceId => { + void (this.modelDisposed ? Promise.resolve() : this.model.selectDevice(request.requestId, deviceId)) + .finally(() => { + if (this.devices.get(request.requestId) === picker) { + this.devices.deleteAndDispose(request.requestId); + } + }) + .catch(error => this.reportError(error)); + }); + this.devices.set(request.requestId, picker); + picker.show(); + } + + private async requestPermission(origin: string, category: PermissionCategory): Promise { + const key = `${origin}\0${category}`; + if (this.permissions.has(key)) { + return; + } + const cancellation = new CancellationTokenSource(); + const token = cancellation.token; + let settled = false; + const pending = toDisposable(() => { + cancellation.dispose(true); + if (!settled && !this.modelDisposed) { + settled = true; + void this.model.setPermissions(origin, [{ category, state: null }]).catch(error => this.reportError(error)); + } + }); + this.permissions.set(key, pending); + const descriptor = PERMISSION_CATEGORY_DESCRIPTORS[category]; + try { + const { result } = await this.dialogService.prompt({ + type: Severity.Info, + message: localize('browser.permissions.prompt', "{0} wants access to {1}", displayBrowserOrigin(origin), descriptor.label), + detail: `\u2022 ${descriptor.description}`, + custom: true, + token, + buttons: [ + { label: localize('browser.permissions.allow', "Allow"), run: () => 'allow' }, + { label: localize('browser.permissions.block', "Block"), run: () => 'deny' }, + ], + cancelButton: true, + }); + if (token.isCancellationRequested) { + return; + } + settled = true; + await this.model.setPermissions(origin, [{ category, state: result ?? null }]); + } finally { + if (this.permissions.get(key) === pending) { + this.permissions.deleteAndDispose(key); + } + } + } + + private reportError(error: unknown): void { + this.logService.error('Browser permission request failed.', error); + if (!this.modelDisposed && !this._store.isDisposed) { + this.notificationService.error(localize('browser.permissions.failed', "The browser permission request could not be completed.")); + } + } +} + +interface DevicePickItem extends IQuickPickItem { + readonly deviceId: string; +} + +interface IDevicePickerHandle extends IDisposable { + show(): void; + update(request: IBrowserViewDeviceRequest): void; +} + +function deviceTypeLabel(deviceType: BrowserDeviceType): string { + switch (deviceType) { + case 'usb': return localize('browser.device.kind.usb', "a USB device"); + case 'serial': return localize('browser.device.kind.serial', "a serial port"); + case 'hid': return localize('browser.device.kind.hid', "an HID device"); + case 'bluetooth': return localize('browser.device.kind.bluetooth', "a Bluetooth device"); + default: assertNever(deviceType); + } +} + +function createDevicePicker( + quickInputService: IQuickInputService, + origin: string, + request: IBrowserViewDeviceRequest, + onSelect: (deviceId: string | null) => void, +): IDevicePickerHandle { + const disposables = new DisposableStore(); + const picker = disposables.add(quickInputService.createQuickPick()); + picker.title = localize('browser.device.title', "{0} wants to connect to {1}", displayBrowserOrigin(origin), deviceTypeLabel(request.deviceType)); + picker.placeholder = localize('browser.device.placeholder', "Select a device to connect to"); + picker.matchOnDescription = true; + picker.ignoreFocusOut = true; + picker.busy = true; + + let resolved = false; + let finished = false; + const finish = () => { + if (finished) { + return; + } + finished = true; + disposables.dispose(); + }; + const resolve = (deviceId: string | null) => { + if (resolved) { + return; + } + resolved = true; + onSelect(deviceId); + }; + const setDevices = (devices: IBrowserViewDeviceRequest['devices']) => { + const activeId = picker.activeItems[0]?.deviceId; + const items: DevicePickItem[] = devices.map(device => ({ label: device.label, description: device.detail, deviceId: device.deviceId })); + picker.items = items; + if (activeId !== undefined) { + const active = items.find(item => item.deviceId === activeId); + if (active) { + picker.activeItems = [active]; + } + } + }; + setDevices(request.devices); + disposables.add(picker.onDidAccept(() => { + const pick = picker.selectedItems[0]; + if (pick) { + resolve(pick.deviceId); + finish(); + } + })); + disposables.add(picker.onDidHide(() => { + resolve(null); + finish(); + })); + return { + show: () => picker.show(), + update: next => { + if (!finished) { + setDevices(next.devices); + } + }, + dispose: () => { + resolve(null); + finish(); + }, + }; +} + +export function displayBrowserOrigin(origin: string): string { + try { + return new URL(origin).host || origin; + } catch { + return origin; + } +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index b2a51971fcfa1..ef7a7da72dc14 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { BrowserViewCommandId, BrowserViewStorageScope, IBrowserViewEditorOpenOptions, IBrowserViewInfo, IBrowserViewOwner, IBrowserViewService, IBrowserViewTheme, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; +import { BrowserViewCommandId, BrowserViewStorageScope, externalBrowserViewStorageAffinity, IBrowserViewEditorOpenOptions, IBrowserViewInfo, IBrowserViewOwner, IBrowserViewService, IBrowserViewTheme, ipcBrowserViewChannelName } from '../../../../platform/browserView/common/browserView.js'; import { BrowserViewSharingState, IBrowserViewWorkbenchService, IBrowserViewModel, BrowserViewModel, IBrowserViewContextualFilter, IBrowserViewFilterContext, IBrowserViewOpenHandler, IBrowserViewWorkbenchCreateOptions } from '../common/browserView.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; @@ -11,7 +11,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { process } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService, PreferredGroup, SIDE_GROUP, USE_MODAL_EDITOR_SETTING, UseModalEditorMode } from '../../../services/editor/common/editorService.js'; import { mainWindow } from '../../../../base/browser/window.js'; @@ -42,9 +42,11 @@ import { INativeWorkbenchEnvironmentService } from '../../../services/environmen import { ITunnelProxyInfo } from '../../../../platform/tunnel/common/tunnelProxy.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { raceTimeout } from '../../../../base/common/async.js'; +import { CancellationError } from '../../../../base/common/errors.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { localize } from '../../../../nls.js'; +import { createBrowserCanvasTheme } from './browserCanvasTheme.js'; export const BrowserMaxHistoryEntriesSettingId = 'workbench.browser.maxHistoryEntries'; export const BrowserRemoteProxyEnabledSettingId = 'workbench.browser.enableRemoteProxy'; @@ -74,9 +76,14 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV private readonly _browserViewService: IBrowserViewService; private readonly _known = new Map(); + private readonly _knownInputListeners = this._register(new DisposableMap()); + private readonly _externalModels = this._register(new DisposableMap()); + private readonly _externalModelListeners = this._register(new DisposableMap()); + private readonly _externalPending = new Map }>(); private readonly _contextualFilters = new Set(); private readonly _openHandlers = new Set(); private readonly _mainWindowId: number; + private readonly _existingViewsInitialized: Promise; /** Latest tunnel-proxy credentials pushed from the local extension host. */ private _remoteProxyInfo: ITunnelProxyInfo | undefined; @@ -140,17 +147,17 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV // Send the full per-window configuration as a single unit, and resend it // whenever any of its inputs change. - this._updateWindowConfiguration(); + void this._updateWindowConfiguration(); const chatEnabledKeys = new Set(ChatContextKeys.enabled.keys()); - this._register(this.keybindingService.onDidUpdateKeybindings(() => this._updateWindowConfiguration())); - this._register(this.themeService.onDidColorThemeChange(() => this._updateWindowConfiguration())); - this._register(this.accessibilityService.onDidChangeReducedMotion(() => this._updateWindowConfiguration())); - this._register(this.workspaceTrustManagementService.onDidChangeTrustedFolders(() => this._updateWindowConfiguration())); - this._register(this.workspaceTrustManagementService.onDidChangeTrust(() => this._updateWindowConfiguration())); - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => this._updateWindowConfiguration())); + this._register(this.keybindingService.onDidUpdateKeybindings(() => void this._updateWindowConfiguration())); + this._register(this.themeService.onDidColorThemeChange(() => void this._updateWindowConfiguration())); + this._register(this.accessibilityService.onDidChangeReducedMotion(() => void this._updateWindowConfiguration())); + this._register(this.workspaceTrustManagementService.onDidChangeTrustedFolders(() => void this._updateWindowConfiguration())); + this._register(this.workspaceTrustManagementService.onDidChangeTrust(() => void this._updateWindowConfiguration())); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => void this._updateWindowConfiguration())); this._register(this.contextKeyService.onDidChangeContext(e => { if (e.affectsSome(chatEnabledKeys)) { - this._updateWindowConfiguration(); + void this._updateWindowConfiguration(); } })); this._register(this.configurationService.onDidChangeConfiguration(e => { @@ -162,7 +169,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV this.notificationService.info(localize('browser.networkFilteringEnabled', "Agent access to browser tabs was revoked because network filtering was enabled.")); } if (e.affectsConfiguration(BrowserMaxHistoryEntriesSettingId) || e.affectsConfiguration(BrowserRemoteProxyEnabledSettingId)) { - this._updateWindowConfiguration(); + void this._updateWindowConfiguration(); } })); @@ -179,14 +186,14 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV } })); - // Start asynchronously creating models for all views we already own. - void this._initializeExistingViews().catch(e => { + this._existingViewsInitialized = this._initializeExistingViews(); + void this._existingViewsInitialized.catch(e => { this.logService.error('[BrowserViewWorkbenchService] Failed to initialize existing browser views.', e); }); // Listen for new browser views this._register(this._browserViewService.onDidCreateBrowserView(e => { - if (e.info.host.windowId !== this._mainWindowId) { + if (e.info.host.windowId !== this._mainWindowId || e.info.presentation) { return; // Not for this window } @@ -214,7 +221,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV setRemoteProxyInfo(info: ITunnelProxyInfo | undefined): void { this._remoteProxyInfo = info; - this._updateWindowConfiguration(); + void this._updateWindowConfiguration(); } getKnownBrowserViews(): Map { @@ -367,8 +374,56 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV return this._getOrCreateLazy(data); } + async getOrCreateExternalBrowserView(id: string, resource: URI, initialUrl: string): Promise { + if (this._known.has(id)) { + throw new Error('An ordinary browser input already owns this native view.'); + } + const pending = this._externalPending.get(id); + if (pending) { + if (!isEqual(pending.resource, resource)) { + throw new Error('A different external presentation already owns this native view.'); + } + return pending.promise; + } + const promise = this._createExternalModel(id, resource, initialUrl); + this._externalPending.set(id, { resource, promise }); + try { + return await promise; + } finally { + this._externalPending.delete(id); + } + } + + private async _createExternalModel(id: string, resource: URI, initialUrl: string): Promise { + await this._existingViewsInitialized; + await this.workspaceTrustManagementService.workspaceTrustInitialized; + if (this._store.isDisposed) { + throw new CancellationError(); + } + await this._updateWindowConfiguration(); + if (this._store.isDisposed) { + throw new CancellationError(); + } + const info = await this._browserViewService.getOrCreateBrowserView(id, { + presentation: { type: 'external', resource }, + host: { windowId: this._mainWindowId }, + owner: { type: 'user' }, + initialAudiences: [], + session: { scope: BrowserViewStorageScope.Agent, affinity: externalBrowserViewStorageAffinity(resource) }, + initialUrl + }); + if (this._store.isDisposed) { + await this._browserViewService.destroyBrowserView(info.id); + throw new CancellationError(); + } + return this._createModel(info); + } + private _getOrCreateLazy(data: IBrowserEditorInputData, model?: IBrowserViewModel, createOptions?: IBrowserViewWorkbenchCreateOptions): BrowserEditorInput { const { id, associatedResource } = data; + if (this._externalModels.has(id) || this._externalPending.has(id)) { + throw new Error('An external presentation already owns this native view.'); + } if (!this._known.has(id)) { const input = this.instantiationService.createInstance(BrowserEditorInput, data, async () => { const info = await this._browserViewService.getOrCreateBrowserView( @@ -387,10 +442,11 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV ); return this._createModel(info); }); - input.onWillDispose(() => { + this._knownInputListeners.set(id, input.onWillDispose(() => { + this._knownInputListeners.deleteAndDispose(id); this._known.delete(id); this._onDidChangeBrowserViews.fire(); - }); + })); if (model) { input.model = model; } @@ -435,21 +491,30 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV } /** - * Fetch all views owned by this window from the main service and create - * models for them so they are available synchronously. + * Restore ordinary browser models and release external views left by the + * previous renderer before their logical owners can attach replacements. */ private async _initializeExistingViews(): Promise { const views = await this._browserViewService.getBrowserViews(this._mainWindowId); + const externalViewDisposals: Promise[] = []; for (const info of views) { - this._createModel(info); + if (info.presentation) { + externalViewDisposals.push(this._browserViewService.destroyBrowserView(info.id)); + } else { + this._createModel(info); + } } + await Promise.all(externalViewDisposals); } private _createModel(info: IBrowserViewInfo, initialUrl?: string): IBrowserViewModel { const associatedResource = URI.revive(info.associatedResource); // Don't double-create const input = this._known.get(info.id); - const existing = input?.model; + if (info.presentation ? input !== undefined : this._externalModels.has(info.id)) { + throw new Error('Native browser presentation cannot change its editor registration.'); + } + const existing = input?.model ?? this._externalModels.get(info.id); if (existing) { return existing; } @@ -464,7 +529,19 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV : initialUrl ? { ...info.state, url: initialUrl } : info.state; - const model = this.instantiationService.createInstance(BrowserViewModel, info.id, info.host, info.owner, associatedResource, state, this._browserViewService); + const model = this.instantiationService.createInstance(BrowserViewModel, info.id, info.host, info.owner, associatedResource, state, this._browserViewService, info.presentation); + + if (info.presentation) { + const listeners = new DisposableStore(); + this._externalModelListeners.set(info.id, listeners); + this._externalModels.set(info.id, model); + listeners.add(model.onWillDispose(() => { + this._externalModels.deleteAndLeak(info.id); + this._externalModelListeners.deleteAndDispose(info.id); + })); + listeners.add(model.onDidClose(() => model.dispose())); + return model; + } // Sanity: both pass and assign the model to be sure. It will no-op if already set. this._getOrCreateLazy({ id: info.id, associatedResource, url: initialUrl }, model).model = model; @@ -542,9 +619,10 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV return undefined; } - private _updateWindowConfiguration(): void { - void this._browserViewService.updateWindowConfiguration(this._mainWindowId, { + private _updateWindowConfiguration(): Promise { + const result = this._browserViewService.updateWindowConfiguration(this._mainWindowId, { theme: this._getTheme(), + canvasTheme: createBrowserCanvasTheme(this.themeService.getColorTheme(), DEFAULT_FONT_FAMILY), keybindings: this._getKeybindings(), aiFeaturesDisabled: !this.contextKeyService.contextMatchesRules(ChatContextKeys.enabled), maxHistoryEntries: this.configurationService.getValue(BrowserMaxHistoryEntriesSettingId), @@ -552,6 +630,8 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV trustedFileRoots: this._getTrustedFileRoots(), trustAllFiles: !this.workspaceTrustEnablementService.isWorkspaceTrustEnabled(), }); + void result.catch(error => this.logService.error('[BrowserViewWorkbenchService] Failed to update native browser configuration.', error)); + return result; } private _getKeybindings(): { [commandId: string]: string } { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts index 102b53b3758e1..275608684806f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorErrorFeatures.ts @@ -93,7 +93,8 @@ class BrowserEditorErrorFeatures extends BrowserEditorContribution { const error = model.error; this._updateCertState(); - if (!error) { + if (!error || error.fileAccessDenied) { + this._clearContent(); this._element.style.display = 'none'; return; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts index dc8a9956881d4..d17ad286661c3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts @@ -5,19 +5,16 @@ import { localize, localize2 } from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { assertNever } from '../../../../../base/common/assert.js'; +import { DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IQuickInputButton, IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; -import { INotificationService, Severity } from '../../../../../platform/notification/common/notification.js'; -import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IBrowserViewDeviceRequest, BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { ALL_PERMISSION_CATEGORIES, - BrowserDeviceType, BrowserPermissionStore, PERMISSION_CATEGORY_DESCRIPTORS, PermissionCategory, @@ -27,6 +24,7 @@ import { } from '../../../../../platform/browserView/common/browserPermissions.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserViewPermissionHandler, displayBrowserOrigin } from '../browserViewPermissions.js'; import { BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_HAS_URL, @@ -46,99 +44,32 @@ import { */ export class BrowserPermissionsFeature extends BrowserEditorContribution { - private readonly _modelDisposables = this._register(new DisposableStore()); + private readonly _permissionHandler = this._register(new MutableDisposable()); private _model: IBrowserViewModel | undefined; private _permissions: BrowserPermissionStore | undefined; - /** Open device choosers keyed by request id, so updates reach the right one. */ - private readonly _devicePickers = new Map(); - constructor( editor: BrowserEditor, @IQuickInputService private readonly _quickInputService: IQuickInputService, @INotificationService private readonly _notificationService: INotificationService, - @IDialogService private readonly _dialogService: IDialogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(editor); } protected override onModelAttached(): void { - this._modelDisposables.clear(); this._model = this.editor.model!; this._permissions = this._model.permissions; - this._modelDisposables.add(this._model.onDidRequestPermission(e => { - if (e.device) { - this._onDidRequestDevice(e.origin, e.device); - } else { - void this._onDidRequestPermission(e.origin, e.category); - } - })); - // Close any open device choosers when the model goes away. - this._modelDisposables.add(toDisposable(() => this._closeDevicePickers())); + this._permissionHandler.value = this._instantiationService.createInstance(BrowserViewPermissionHandler, this._model); } override onModelDetached(): void { - this._modelDisposables.clear(); + this._permissionHandler.clear(); this._model = undefined; this._permissions = undefined; } - private _closeDevicePickers(): void { - for (const picker of [...this._devicePickers.values()]) { - picker.dispose(); - } - this._devicePickers.clear(); - } - - private _onDidRequestDevice(origin: string, request: IBrowserViewDeviceRequest): void { - const existing = this._devicePickers.get(request.requestId); - if (existing) { - existing.update(request); - return; - } - const model = this._model; - if (!model) { - return; - } - const handle = showDevicePicker(this._quickInputService, model, origin, request, () => this._devicePickers.delete(request.requestId)); - this._devicePickers.set(request.requestId, handle); - } - - private async _onDidRequestPermission(origin: string, category: PermissionCategory): Promise { - const model = this._model; - if (!model) { - return; - } - const descriptor = PERMISSION_CATEGORY_DESCRIPTORS[category]; - const { result } = await this._dialogService.prompt({ - type: Severity.Info, - message: localize('browser.permissions.prompt', "{0} wants access to {1}", displayOrigin(origin), descriptor.label), - detail: `• ${descriptor.description}`, - buttons: [ - { - label: localize('browser.permissions.allow', "Allow"), - run: () => 'allow', - }, - { - label: localize('browser.permissions.block', "Block"), - run: () => 'deny', - }, - ], - // Dismissing leaves the request undecided. The main process settles - // the page's request on navigation / teardown (or a timeout), so a - // late answer here is harmless. - cancelButton: true, - }); - if (result === 'allow' || result === 'deny') { - void model.setPermissions(origin, [{ category, state: result }]); - } else { - // Signal an explicit cancel so the pending page request rejects - // immediately without recording a persisted decision. - void model.setPermissions(origin, [{ category, state: null }]); - } - } - showManagementPicker(): void { const model = this._model; const permissions = this._permissions; @@ -156,109 +87,6 @@ export class BrowserPermissionsFeature extends BrowserEditorContribution { BrowserEditor.registerContribution(BrowserPermissionsFeature); -// -- Device chooser -------------------------------------------------- - -interface DevicePickItem extends IQuickPickItem { - readonly deviceId: string; -} - -/** Handle to a live device chooser so it can be updated or force-closed. */ -interface IDevicePickerHandle { - /** Apply an updated device list. */ - update(request: IBrowserViewDeviceRequest): void; - /** Force-close the chooser, cancelling the request if still pending. */ - dispose(): void; -} - -function deviceTypeLabel(deviceType: BrowserDeviceType): string { - switch (deviceType) { - case 'usb': return localize('browser.device.kind.usb', "a USB device"); - case 'serial': return localize('browser.device.kind.serial', "a serial port"); - case 'hid': return localize('browser.device.kind.hid', "an HID device"); - case 'bluetooth': return localize('browser.device.kind.bluetooth', "a Bluetooth device"); - default: assertNever(deviceType); - } -} - -/** - * Show a live-updating chooser for a hardware-device request. The list refreshes - * as devices are discovered (re-fired with the same request id); accepting picks - * a device and dismissing cancels the request. Exactly one of select/cancel is - * reported back to the model. - */ -function showDevicePicker(quickInputService: IQuickInputService, model: IBrowserViewModel, origin: string, request: IBrowserViewDeviceRequest, onDone: () => void): IDevicePickerHandle { - const disposables = new DisposableStore(); - const picker = disposables.add(quickInputService.createQuickPick()); - picker.title = localize('browser.device.title', "{0} wants to connect to {1}", displayOrigin(origin), deviceTypeLabel(request.deviceType)); - picker.placeholder = localize('browser.device.placeholder', "Select a device to connect to"); - picker.matchOnDescription = true; - picker.ignoreFocusOut = true; - // Still scanning: the list may keep growing until the user picks or cancels. - picker.busy = true; - - let resolved = false; - let finished = false; - - const finish = () => { - if (finished) { - return; - } - finished = true; - disposables.dispose(); - onDone(); - }; - - // Report a single decision to the model: a chosen id, or null to cancel. - const resolve = (deviceId: string | null) => { - if (resolved) { - return; - } - resolved = true; - void model.selectDevice(request.requestId, deviceId); - }; - - const setDevices = (devices: readonly { deviceId: string; label: string; detail?: string }[]) => { - const activeId = picker.activeItems[0]?.deviceId; - const items: DevicePickItem[] = devices.map(device => ({ label: device.label, description: device.detail, deviceId: device.deviceId })); - picker.items = items; - if (activeId !== undefined) { - const active = items.find(item => item.deviceId === activeId); - if (active) { - picker.activeItems = [active]; - } - } - }; - - setDevices(request.devices); - - disposables.add(picker.onDidAccept(() => { - const pick = picker.selectedItems[0]; - if (!pick) { - return; - } - resolve(pick.deviceId); - finish(); - })); - - disposables.add(picker.onDidHide(() => { - // Dismissed without a pick cancels the request. - resolve(null); - finish(); - })); - - picker.show(); - - return { - update: (next: IBrowserViewDeviceRequest) => { - setDevices(next.devices); - }, - dispose: () => { - resolve(null); - finish(); - }, - }; -} - // -- Management picker ----------------------------------------------- interface PermissionPickItem extends IQuickPickItem { @@ -273,7 +101,7 @@ interface PermissionItemButton extends IQuickInputButton { function showPermissionsPicker(quickInputService: IQuickInputService, model: IBrowserViewModel, permissions: BrowserPermissionStore, origin: string): void { const disposables = new DisposableStore(); const picker = disposables.add(quickInputService.createQuickPick()); - picker.title = localize('browser.permissions.title', "Permissions for {0}", displayOrigin(origin)); + picker.title = localize('browser.permissions.title', "Permissions for {0}", displayBrowserOrigin(origin)); picker.placeholder = localize('browser.permissions.placeholder', "Filter permissions"); picker.sortByLabel = false; picker.ignoreFocusOut = true; @@ -406,14 +234,6 @@ function showPermissionsPicker(quickInputService: IQuickInputService, model: IBr } } -function displayOrigin(origin: string): string { - try { - return new URL(origin).host || origin; - } catch { - return origin; - } -} - // -- Actions ---------------------------------------------------------- class ManageBrowserPermissionsAction extends Action2 { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts index ccca278dd48c2..ca96575bffd39 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts @@ -3,316 +3,53 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../../../nls.js'; -import { $, addDisposableListener, EventType, registerExternalFocusChecker } from '../../../../../base/browser/dom.js'; import { getZoomFactor } from '../../../../../base/browser/browser.js'; -import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; -import { encodeBase64, VSBuffer } from '../../../../../base/common/buffer.js'; -import { DisposableStore, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { - IBrowserViewKeyDownEvent, -} from '../../../../../platform/browserView/common/browserView.js'; +import { snapBrowserViewBounds } from '../../../../../platform/browserView/common/browserView.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IBrowserViewModel } from '../../common/browserView.js'; -import { - BrowserEditor, - BrowserEditorContribution, - BrowserWidgetLocation, - IBrowserEditorWidget, - IContainerLayout, - IContainerLayoutOverride, -} from '../browserEditor.js'; -import { BrowserOverlayManager, BrowserOverlayType } from '../overlayManager.js'; +import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, IBrowserEditorWidget, IContainerLayoutOverride } from '../browserEditor.js'; +import { WebContentsViewHost } from '../webContentsViewHost.js'; -/** - * Default browser renderer: drives a Chromium WebContentsView. - * - * Owns everything that exists only because of how the WCV behaves: - * - placeholder screenshot to mask the page during show/hide swaps, - * - overlay-pause UI for when a workbench modal sits on top of the WCV, - * - the focus dance that bounces focus between the workbench DOM and the WCV, - * - native key event forwarding through the keybinding service, - * - the pixel-snap layout contribution that keeps the container on physical - * pixel boundaries (registered late in the priority chain so it refines - * whatever sizing other contributions produce). - * - * An alternative renderer (e.g. an in-DOM iframe) would replace this - * contribution and need none of the above. - */ class WebContentsViewRendererFeature extends BrowserEditorContribution { - - private _container: HTMLElement | undefined; - private _model: IBrowserViewModel | undefined; - private _editorVisible = false; - private _overlayObscured = false; - - private readonly _placeholderScreenshot = $('.browser-placeholder-screenshot'); - private readonly _overlayPauseEl = $('.browser-overlay-paused'); - private readonly _overlayManager: BrowserOverlayManager; - - private readonly _placeholderContent: IBrowserEditorWidget; - private readonly _overlayPauseContent: IBrowserEditorWidget; - - private readonly _screenshotHandle = this._register(new MutableDisposable()); - private _focusTimeout: ReturnType | undefined; + private readonly host: WebContentsViewHost; constructor( editor: BrowserEditor, - @ILogService private readonly logService: ILogService, - @IKeybindingService private readonly keybindingService: IKeybindingService, + @IInstantiationService instantiationService: IInstantiationService, ) { super(editor); - - this._overlayManager = this._register(new BrowserOverlayManager(editor.window)); - - // Build overlay-pause DOM - const message = $('.browser-overlay-paused-message'); - const heading = $('.browser-overlay-paused-heading'); - const detail = $('.browser-overlay-paused-detail'); - heading.textContent = localize('browser.overlayPauseHeading.notification', "Paused due to Notification"); - detail.textContent = localize('browser.overlayPauseDetail.notification', "Dismiss the notification to continue using the browser."); - message.appendChild(heading); - message.appendChild(detail); - this._overlayPauseEl.appendChild(message); - - this._placeholderContent = { location: BrowserWidgetLocation.ContentArea, element: this._placeholderScreenshot, order: 100 }; - this._overlayPauseContent = { location: BrowserWidgetLocation.ContentArea, element: this._overlayPauseEl, order: 200 }; - - this._register(this._overlayManager.onDidChangeOverlayState(() => this._refreshOverlayObscured())); - this._refresh(); + this.host = this._register(instantiationService.createInstance(WebContentsViewHost, editor.window, () => editor.ensureBrowserFocus())); } override get widgets(): readonly IBrowserEditorWidget[] { - return [this._placeholderContent, this._overlayPauseContent]; + return [ + { location: BrowserWidgetLocation.ContentArea, element: this.host.screenshotElement, order: 100 }, + { location: BrowserWidgetLocation.ContentArea, element: this.host.pauseElement, order: 200 }, + ]; } override beforeContainerLayout(): IContainerLayoutOverride { return { padding: { top: 3, right: 3, bottom: 3, left: 3 }, - - // Snap CSS-pixel values down so `v × hostZoom` is an exact integer: - // main places the WCV at `round(v × hostZoom) × systemDPR` physical - // pixels while CSS renders it at `v × hostZoom × systemDPR`, so this - // collapses main's rounding to a no-op and keeps the WebContentsView - // aligned with the placeholder screenshot. We snap the absolute - // origin (pane origin + local offset) then derive the corresponding - // local position so the DOM element and the WCV land on the same - // physical pixel. Runs late so it refines whatever sizing upstream - // contributions (e.g. device emulation) produced. - compute: (current, pane): IContainerLayout => { - const z = getZoomFactor(this.editor.window); - const snap = (v: number) => Math.floor(v * z) / z; - const absLeft = pane.originX + (current.left ?? 0); - const absTop = pane.originY + (current.top ?? 0); - return { - ...current, - width: snap(current.width), - height: snap(current.height), - left: snap(absLeft) - pane.originX, - top: snap(absTop) - pane.originY, - }; - }, priority: 1000, + compute: (current, pane) => { + const bounds = snapBrowserViewBounds({ + x: pane.originX + (current.left ?? 0), + y: pane.originY + (current.top ?? 0), + width: current.width, + height: current.height, + }, getZoomFactor(this.editor.window)); + return { ...current, width: bounds.width, height: bounds.height, left: bounds.x - pane.originX, top: bounds.y - pane.originY }; + }, }; } - override onContainerCreated(container: HTMLElement): void { - this._container = container; - - this._register(addDisposableListener(container, EventType.FOCUS, () => this.tryFocus())); - this._register(addDisposableListener(container, EventType.BLUR, () => this._cancelFocusTimeout())); - - // Cross-window focus logic uses this checker because the WCV lives - // outside the DOM tree and can't be detected with activeElement. - this._register(registerExternalFocusChecker(() => ({ - hasFocus: this._model?.focused ?? false, - window: this._model?.focused ? this.editor.window : undefined, - }))); - - this._refreshOverlayObscured(); - } - - // -- Base contribution hooks -------------------------------------------- - - override onPaneVisibilityChanged(visible: boolean): void { - if (this._editorVisible === visible) { - return; - } - this._editorVisible = visible; - this._refresh(); - } - - override afterContainerLayout(): void { - // Container moved or resized — overlays that overlap us might have - // shifted relative to the container even though their own DOM didn't - // change. Recompute obscured state so the page can hide accordingly. - this._refreshOverlayObscured(); - } - - override tryFocus(): boolean { - if (!this.editor.input?.url) { - return false; - } - this._container?.focus(); - if (this._focusTimeout || !this._model) { - return true; - } - this._focusTimeout = setTimeout(() => { - this._focusTimeout = undefined; - const doc = this._container?.ownerDocument; - if (!doc?.hasFocus() || doc.activeElement !== this._container) { - return; - } - if (this._model?.visible) { - void this._model.focus(); - } else { - this.editor.ensureBrowserFocus(); - } - }, 10); - return true; - } - - // -- Model lifecycle ---------------------------------------------------- - - protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { - this._model = model; - this._setBackgroundImage(model.screenshot); - - store.add(model.onDidChangeVisibility(() => void this._doScreenshot())); - store.add(model.onDidKeyCommand(keyEvent => void this._handleKeyEvent(keyEvent))); - store.add(model.onDidNavigate(() => this._refresh(true))); - store.add(model.onDidChangeLoadingState(() => this._refresh(true))); - - this._refresh(); - void this._doScreenshot(); - } - - override onModelDetached(): void { - if (this._model) { - void this._model.setVisible(false); - } - this._model = undefined; - this._screenshotHandle.clear(); - this._cancelFocusTimeout(); - this._setBackgroundImage(undefined); - this._refresh(); - } - - override dispose(): void { - this._cancelFocusTimeout(); - super.dispose(); - } - - // -- Internals ---------------------------------------------------------- - - private _shouldShowPage(): boolean { - return this._editorVisible - && !this._overlayObscured - && !!this._model?.url - && !this._model?.error; - } - - /** - * Recompute visibility of our content layers and the underlying page based - * on the latest editor/overlay/model state. - */ - private _refresh(restartScreenshot = false): void { - // Placeholder screenshot: shown whenever there's a page to render - // (covered by the WCV when it's up, visible during hide/show swaps). - const placeholderActive = !!this._model?.url && !this._model?.error; - this._placeholderScreenshot.style.display = placeholderActive ? '' : 'none'; - - // Overlay-pause overlay: fades in when an overlay obscures the page. - const pauseActive = !!this._model?.url && this._editorVisible && this._overlayObscured; - this._overlayPauseEl.classList.toggle('visible', pauseActive); - - if (!this._model) { - return; - } - const show = this._shouldShowPage(); - if (show === this._model.visible) { - if (show && restartScreenshot) { - void this._doScreenshot(); - } - return; - } - if (show) { - void this._model.setVisible(true); - // If the editor container is focused, ensure the WCV gets focus too. - const ownerDoc = this._container?.ownerDocument; - if (ownerDoc?.hasFocus() && ownerDoc.activeElement === this._container) { - this.tryFocus(); - } - } else { - void this._doScreenshot(); - // Defer the hide one frame so the latest screenshot has a chance to paint first. - this.editor.window.requestAnimationFrame(() => { - // Double check that we should still hide the page. - if (this._model && !this._shouldShowPage()) { - void this._model.setVisible(false); - } - }); - } - } - - private _refreshOverlayObscured(): void { - if (!this._container) { - return; - } - const overlays = this._overlayManager.getOverlappingOverlays(this._container); - const obscured = overlays.length > 0; - const hasNotification = overlays.some(o => o.type === BrowserOverlayType.Notification); - this._overlayPauseEl.classList.toggle('show-message', hasNotification); - if (obscured !== this._overlayObscured) { - this._overlayObscured = obscured; - this._refresh(); - } - } - - private async _doScreenshot(): Promise { - this._screenshotHandle.clear(); - if (!this._model?.url || this._model.error || !this._model.visible) { - return; - } - try { - const screenshot = await this._model.captureScreenshot({ quality: 80 }); - this._setBackgroundImage(screenshot); - } catch (error) { - this.logService.error('Failed to capture browser view screenshot', error); - } - const handle = setTimeout(() => void this._doScreenshot(), 1000); - this._screenshotHandle.value = toDisposable(() => clearTimeout(handle)); - } - - private _setBackgroundImage(buffer: VSBuffer | undefined): void { - if (buffer) { - const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`; - this._placeholderScreenshot.style.backgroundImage = `url('${dataUrl}')`; - } else { - this._placeholderScreenshot.style.backgroundImage = ''; - } - } - - private async _handleKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { - if (!this._container) { - return; - } - try { - const syntheticEvent = new KeyboardEvent('keydown', keyEvent); - const standardEvent = new StandardKeyboardEvent(syntheticEvent); - this.keybindingService.dispatchEvent(standardEvent, this._container); - } catch (error) { - this.logService.error('WebContentsViewRendererFeature: Error dispatching key event', error); - } - } - - private _cancelFocusTimeout(): void { - if (this._focusTimeout) { - clearTimeout(this._focusTimeout); - this._focusTimeout = undefined; - } - } + override onContainerCreated(container: HTMLElement): void { this.host.onContainerCreated(container); } + override onPaneVisibilityChanged(visible: boolean): void { this.host.setVisible(visible); } + override afterContainerLayout(): void { this.host.layout(); } + override tryFocus(): boolean { return this.host.tryFocus(); } + protected override onModelAttached(model: IBrowserViewModel): void { this.host.setModel(model); } + override onModelDetached(): void { this.host.setModel(undefined); } } BrowserEditor.registerContribution(WebContentsViewRendererFeature); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts index ba1b497de4000..e44b5733cd220 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts @@ -29,6 +29,8 @@ const OVERLAY_DEFINITIONS: ReadonlyArray<{ className: string; type: BrowserOverl { className: 'monaco-modal-editor-block', type: BrowserOverlayType.Dialog }, { className: 'notifications-center', type: BrowserOverlayType.Notification }, { className: 'notification-toast-container', type: BrowserOverlayType.Notification }, + // Accessible View is absolutely positioned inside a zero-sized context view. + { className: 'accessible-view', type: BrowserOverlayType.Unknown }, // Context view is very generic, so treat the content as unknown { className: 'context-view', type: BrowserOverlayType.Unknown } ]; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/webContentsViewHost.ts b/src/vs/workbench/contrib/browserView/electron-browser/webContentsViewHost.ts new file mode 100644 index 0000000000000..6e4a9aa078a84 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/electron-browser/webContentsViewHost.ts @@ -0,0 +1,307 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/browser.css'; +import { localize } from '../../../../nls.js'; +import { $, addDisposableListener, EventType, registerExternalFocusChecker } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import type { CodeWindow } from '../../../../base/browser/window.js'; +import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IBrowserViewKeyDownEvent } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../common/browserView.js'; +import { BrowserOverlayManager, BrowserOverlayType } from './overlayManager.js'; +import { BrowserFileTrustWidget } from './browserFileTrustWidget.js'; + +const originalHtmlElementFocus = HTMLElement.prototype.focus; + +/** Keep the DOM focus anchor without taking native focus away from the guest page. */ +export function focusWebContentsViewContainer(container: HTMLElement): void { + originalHtmlElementFocus.call(container); + container.ownerDocument.getSelection()?.removeAllRanges(); +} + +/** Native content presentation, independent of browser chrome and editor-input ownership. */ +export class WebContentsViewHost extends Disposable { + + private _container: HTMLElement | undefined; + private _model: IBrowserViewModel | undefined; + private _editorVisible = false; + private _overlayObscured = false; + + private readonly _placeholderScreenshot = $('.browser-placeholder-screenshot'); + private readonly _overlayPauseEl = $('.browser-overlay-paused'); + private readonly _overlayManager: BrowserOverlayManager; + private readonly _fileTrust: BrowserFileTrustWidget; + + private readonly _modelStore = this._register(new DisposableStore()); + private readonly _screenshotHandle = this._register(new MutableDisposable()); + private _focusTimeout: ReturnType | undefined; + private _screenshotSequence = 0; + + get screenshotElement(): HTMLElement { return this._placeholderScreenshot; } + get pauseElement(): HTMLElement { return this._overlayPauseEl; } + + constructor( + private readonly targetWindow: CodeWindow, + private readonly ensureBrowserFocus: () => void, + @ILogService private readonly logService: ILogService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._overlayManager = this._register(new BrowserOverlayManager(targetWindow)); + this._fileTrust = this._register(instantiationService.createInstance(BrowserFileTrustWidget)); + + // Build overlay-pause DOM + const message = $('.browser-overlay-paused-message'); + const heading = $('.browser-overlay-paused-heading'); + const detail = $('.browser-overlay-paused-detail'); + heading.textContent = localize('browser.overlayPauseHeading.notification', "Paused due to Notification"); + detail.textContent = localize('browser.overlayPauseDetail.notification', "Dismiss the notification to continue using the browser."); + message.appendChild(heading); + message.appendChild(detail); + this._overlayPauseEl.appendChild(message); + + this._register(this._overlayManager.onDidChangeOverlayState(() => this._refreshOverlayObscured())); + this._refresh(); + } + + onContainerCreated(container: HTMLElement): void { + this._container = container; + container.appendChild(this._fileTrust.element); + this._register(toDisposable(() => this._fileTrust.element.remove())); + + this._register(addDisposableListener(container, EventType.FOCUS, () => this.tryFocus())); + this._register(addDisposableListener(container, EventType.BLUR, () => this._cancelFocusTimeout())); + + // Cross-window focus logic uses this checker because the WCV lives + // outside the DOM tree and can't be detected with activeElement. + this._register(registerExternalFocusChecker(() => ({ + hasFocus: this._model?.focused ?? false, + window: this._model?.focused ? this.targetWindow : undefined, + }))); + + this._refreshOverlayObscured(); + } + + // -- Base contribution hooks -------------------------------------------- + + setVisible(visible: boolean): void { + if (this._editorVisible === visible) { + return; + } + this._editorVisible = visible; + this._refresh(); + } + + layout(): void { + // Container moved or resized — overlays that overlap us might have + // shifted relative to the container even though their own DOM didn't + // change. Recompute obscured state so the page can hide accordingly. + this._refreshOverlayObscured(); + } + + tryFocus(): boolean { + if (this._fileTrust.focus()) { + return true; + } + if (!this._model?.url) { + return false; + } + this._container?.focus(); + if (this._focusTimeout || !this._model) { + return true; + } + this._focusTimeout = setTimeout(() => { + this._focusTimeout = undefined; + const doc = this._container?.ownerDocument; + if (!doc?.hasFocus() || doc.activeElement !== this._container) { + return; + } + if (this._model?.visible) { + void this._model.focus(); + } else { + this.ensureBrowserFocus(); + } + }, 10); + return true; + } + + // -- Model lifecycle ---------------------------------------------------- + + setModel(model: IBrowserViewModel | undefined): void { + if (model === this._model) { + return; + } + this._detachModel(); + if (!model) { + return; + } + this._model = model; + this._setBackgroundImage(model.screenshot); + + const store = this._modelStore; + store.add(model.onDidChangeVisibility(() => void this._doScreenshot())); + store.add(model.onDidKeyCommand(keyEvent => void this._handleKeyEvent(keyEvent))); + store.add(model.onDidNavigate(() => this._refresh(true))); + store.add(model.onDidChangeLoadingState(() => this._refresh(true))); + store.add(model.onWillDispose(() => this._detachModel(true))); + + this._refresh(); + void this._doScreenshot(); + } + + private _detachModel(modelDisposing = false): void { + this._screenshotSequence++; + const model = this._model; + if (model && !modelDisposing) { + void model.setVisible(false).catch(error => { + this.logService.error('WebContentsViewHost: Failed to hide detached browser view', error); + }); + } + this._model = undefined; + this._modelStore.clear(); + this._screenshotHandle.clear(); + this._cancelFocusTimeout(); + this._setBackgroundImage(undefined); + this._refresh(); + } + + override dispose(): void { + this._detachModel(); + super.dispose(); + } + + // -- Internals ---------------------------------------------------------- + + private _shouldShowPage(): boolean { + return this._editorVisible + && !this._overlayObscured + && !!this._model?.url + && !this._model?.error; + } + + /** + * Recompute visibility of our content layers and the underlying page based + * on the latest editor/overlay/model state. + */ + private _refresh(restartScreenshot = false): void { + this._fileTrust.update(this._model); + if (restartScreenshot) { + this._screenshotSequence++; + } + if (this._model?.error) { + this._screenshotHandle.clear(); + this._setBackgroundImage(undefined); + } + // Placeholder screenshot: shown whenever there's a page to render + // (covered by the WCV when it's up, visible during hide/show swaps). + const placeholderActive = !!this._model?.url && !this._model?.error; + this._placeholderScreenshot.style.display = placeholderActive ? '' : 'none'; + + // Overlay-pause overlay: fades in when an overlay obscures the page. + const pauseActive = !!this._model?.url && this._editorVisible && this._overlayObscured; + this._overlayPauseEl.classList.toggle('visible', pauseActive); + + if (!this._model) { + return; + } + const show = this._shouldShowPage(); + if (show === this._model.visible) { + if (show && restartScreenshot) { + void this._doScreenshot(); + } + return; + } + if (show) { + void this._model.setVisible(true); + // If the editor container is focused, ensure the WCV gets focus too. + const ownerDoc = this._container?.ownerDocument; + if (ownerDoc?.hasFocus() && ownerDoc.activeElement === this._container) { + this.tryFocus(); + } + } else { + void this._doScreenshot(); + // Defer the hide one frame so the latest screenshot has a chance to paint first. + this.targetWindow.requestAnimationFrame(() => { + // Double check that we should still hide the page. + if (this._model && !this._shouldShowPage()) { + void this._model.setVisible(false); + } + }); + } + } + + private _refreshOverlayObscured(): void { + if (!this._container) { + return; + } + const overlays = this._overlayManager.getOverlappingOverlays(this._container); + const obscured = overlays.length > 0; + const hasNotification = overlays.some(o => o.type === BrowserOverlayType.Notification); + this._overlayPauseEl.classList.toggle('show-message', hasNotification); + if (obscured !== this._overlayObscured) { + this._overlayObscured = obscured; + this._refresh(); + } + } + + private async _doScreenshot(): Promise { + this._screenshotHandle.clear(); + const model = this._model; + const sequence = this._screenshotSequence; + if (!model?.url || model.error || !model.visible) { + return; + } + try { + const screenshot = await model.captureScreenshot({ quality: 80 }); + if (this._model === model && sequence === this._screenshotSequence && !model.error) { + this._setBackgroundImage(screenshot); + } + } catch (error) { + if (this._model === model && model.visible && !this._store.isDisposed) { + this.logService.error('Failed to capture browser view screenshot', error); + } + } + if (this._model === model && sequence === this._screenshotSequence && !model.error && !this._store.isDisposed) { + const handle = setTimeout(() => void this._doScreenshot(), 1000); + this._screenshotHandle.value = toDisposable(() => clearTimeout(handle)); + } + } + + private _setBackgroundImage(buffer: VSBuffer | undefined): void { + if (buffer) { + const dataUrl = `data:image/jpeg;base64,${encodeBase64(buffer)}`; + this._placeholderScreenshot.style.backgroundImage = `url('${dataUrl}')`; + } else { + this._placeholderScreenshot.style.backgroundImage = ''; + } + } + + private async _handleKeyEvent(keyEvent: IBrowserViewKeyDownEvent): Promise { + if (!this._container) { + return; + } + try { + const syntheticEvent = new KeyboardEvent('keydown', keyEvent); + const standardEvent = new StandardKeyboardEvent(syntheticEvent); + this.keybindingService.dispatchEvent(standardEvent, this._container); + } catch (error) { + this.logService.error('WebContentsViewHost: Error dispatching key event', error); + } + } + + private _cancelFocusTimeout(): void { + if (this._focusTimeout) { + clearTimeout(this._focusTimeout); + this._focusTimeout = undefined; + } + } +} diff --git a/src/vs/workbench/contrib/browserView/test/common/browserView.test.ts b/src/vs/workbench/contrib/browserView/test/common/browserView.test.ts index 3577ee7f3710d..fcf62a183ab17 100644 --- a/src/vs/workbench/contrib/browserView/test/common/browserView.test.ts +++ b/src/vs/workbench/contrib/browserView/test/common/browserView.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { Event } from '../../../../../base/common/event.js'; +import { URI } from '../../../../../base/common/uri.js'; import { upcastPartial } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { browserZoomDefaultIndex, BrowserViewStorageScope, IBrowserViewAudience, IBrowserViewService, IBrowserViewState } from '../../../../../platform/browserView/common/browserView.js'; @@ -51,13 +52,14 @@ suite('BrowserViewModel', () => { onDidChangeZoom: Event.None, }); - const createModel = (storageScope: BrowserViewStorageScope, audiences: IBrowserViewAudience[]) => store.add(new BrowserViewModel( + const createModel = (storageScope: BrowserViewStorageScope, audiences: IBrowserViewAudience[], external = false) => store.add(new BrowserViewModel( `browser-${storageScope}-${audiences.length}`, { windowId: 1 }, { type: 'user' }, undefined, createInitialState(storageScope, audiences), browserViewService, + external ? { type: 'external', resource: URI.parse('test-canvas:/instance') } : undefined, browserViewWorkbenchService, upcastPartial({}), upcastPartial({}), @@ -71,10 +73,12 @@ suite('BrowserViewModel', () => { sharedWorkspace: createModel(BrowserViewStorageScope.Workspace, [{ type: 'agent' }]).sharingState, unsharedWorkspace: createModel(BrowserViewStorageScope.Workspace, []).sharingState, unsharedAgent: createModel(BrowserViewStorageScope.Agent, []).sharingState, + external: createModel(BrowserViewStorageScope.Agent, [], true).sharingState, }, { sharedWorkspace: BrowserViewSharingState.Shared, unsharedWorkspace: BrowserViewSharingState.BlockedByNetworkPolicy, unsharedAgent: BrowserViewSharingState.Available, + external: BrowserViewSharingState.Unavailable, }); }); }); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserCanvasTheme.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserCanvasTheme.test.ts new file mode 100644 index 0000000000000..c8aa2244f6146 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserCanvasTheme.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { encodeHex, VSBuffer } from '../../../../../base/common/buffer.js'; +import { ColorScheme } from '../../../../../platform/theme/common/theme.js'; +import { TestColorTheme } from '../../../../../platform/theme/test/common/testThemeService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { createBrowserCanvasTheme } from '../../electron-browser/browserCanvasTheme.js'; + +suite('Browser canvas guest theme', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('maps the complete declared 329-variable contract without leaking arbitrary workbench CSS', async () => { + const theme = createBrowserCanvasTheme(new TestColorTheme(), 'system-ui'); + const names = Object.keys(theme.cssVariables).sort(); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(names.join('\n'))); + assert.deepStrictEqual({ + count: names.length, + contract: encodeHex(VSBuffer.wrap(new Uint8Array(digest))), + stylesheets: Object.keys(theme.stylesheets), + attributes: Object.keys(theme.attributes).sort(), + bounded: Object.values(theme.cssVariables).every(value => value.length > 0 && value.length <= 512 && !/[{};]/.test(value)), + }, { + count: 329, + contract: 'e1078b0f0f3180d84554ef6d3ee2dbae7701e08f2053ad211c4d2992fb5e5a8d', + stylesheets: ['rampa'], + attributes: ['data-color-mode', 'data-dark-theme', 'data-light-theme', 'data-theme-source', 'data-theme-tone', 'data-visual-mode'], + bounded: true, + }); + }); + + test('semantic backgrounds, controls, focus and data colors follow the owning theme', () => { + const theme = createBrowserCanvasTheme(new TestColorTheme({ + 'editor.background': '#102030', 'editor.foreground': '#f0e0d0', 'button.background': '#315a81', + 'button.foreground': '#ffffff', 'focusBorder': '#aa55cc', 'terminal.ansiGreen': '#00ab00', + }), 'Test Font, sans-serif'); + assert.deepStrictEqual({ + background: theme.cssVariables['--background-color-default'], foreground: theme.cssVariables['--text-color-default'], + button: theme.cssVariables['--background-color-button-primary-rest'], focus: theme.cssVariables['--color-focus-outline'], + green: theme.cssVariables['--color-data-green-emphasis'], font: theme.cssVariables['--font-sans'], + }, { background: '#102030', foreground: '#f0e0d0', button: '#315a81', focus: '#aa55cc', green: '#00ab00', font: 'Test Font, sans-serif' }); + }); + + test('fresh payloads change dark/light modes and keep high-contrast outline tokens', () => { + const modes = [ColorScheme.DARK, ColorScheme.LIGHT, ColorScheme.HIGH_CONTRAST_DARK, ColorScheme.HIGH_CONTRAST_LIGHT] + .map(type => createBrowserCanvasTheme(new TestColorTheme({ 'contrastBorder': '#ff0000', 'focusBorder': '#00ff00' }, type), 'system-ui')); + assert.deepStrictEqual(modes.map(theme => ({ + mode: theme.colorScheme, attribute: theme.attributes['data-color-mode'], border: theme.cssVariables['--border-color-default'], focus: theme.cssVariables['--outline-color-focus-default'], + })), [ + { mode: 'dark', attribute: 'dark', border: '#ff0000', focus: '#00ff00' }, + { mode: 'light', attribute: 'light', border: '#ff0000', focus: '#00ff00' }, + { mode: 'dark', attribute: 'dark', border: '#ff0000', focus: '#00ff00' }, + { mode: 'light', attribute: 'light', border: '#ff0000', focus: '#00ff00' }, + ]); + }); + + test('syntax defaults use the public token metadata API', () => { + const source = new class extends TestColorTheme { + override getTokenStyleMetadata(type: string) { + return { foreground: type === 'comment' ? 1 : undefined, bold: undefined, underline: undefined, strikethrough: undefined, italic: undefined }; + } + override get tokenColorMap() { return ['', '#cc8844']; } + }({ 'editor.foreground': '#abcdef' }); + const theme = createBrowserCanvasTheme(source, 'system-ui'); + assert.deepStrictEqual({ comment: theme.cssVariables['--syntax-color-comment'], fallback: theme.cssVariables['--syntax-color-string'] }, { comment: '#cc8844', fallback: '#abcdef' }); + }); +}); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts index ed42294685ff4..683df6db3ab76 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserEditorInput.test.ts @@ -64,6 +64,10 @@ class TestBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { throw new Error('Not implemented for this test.'); } + async getOrCreateExternalBrowserView(): Promise { + throw new Error('Not implemented for this test.'); + } + getOrCreateLazy(data: IBrowserEditorInputData): BrowserEditorInput { this.lastCreate = { id: data.id, diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserFileTrustWidget.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserFileTrustWidget.test.ts new file mode 100644 index 0000000000000..410f8e561ca9c --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserFileTrustWidget.test.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import type { IBrowserViewLoadError } from '../../../../../platform/browserView/common/browserView.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IWorkspaceTrustRequestService, ResourceTrustRequestOptions } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { MANAGE_TRUST_COMMAND_ID } from '../../../workspace/common/workspace.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserFileTrustWidget } from '../../electron-browser/browserFileTrustWidget.js'; + +suite('BrowserFileTrustWidget', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const file = URI.file('/canvas-file-trust/outside/index.html'); + + function createModel() { + let url = file.toString(); + let error: IBrowserViewLoadError | undefined = { url, errorCode: -2, errorDescription: 'ERR_FAILED', fileAccessDenied: true }; + let loading = false; + const loads: string[] = []; + const model = upcastPartial({ + get url() { return url; }, + get error() { return error; }, + get loading() { return loading; }, + presentation: { type: 'external', resource: URI.parse('test-canvas:/owner/chat/instance') }, + loadURL: async value => { loads.push(value); }, + }); + return { + model, loads, + setState: (next: { url?: string; error?: IBrowserViewLoadError; loading?: boolean }) => { + url = next.url ?? url; + error = next.error; + loading = next.loading ?? false; + }, + }; + } + + function createFixture() { + const approval = new DeferredPromise(); + const requests: ResourceTrustRequestOptions[] = []; + const commands: string[] = []; + const notifications: string[] = []; + const errors: Error[] = []; + const widget = store.add(new BrowserFileTrustWidget( + upcastPartial({ + requestResourcesTrust: async options => { requests.push(options); return approval.p; }, + }), + upcastPartial({ + executeCommand: async id => { commands.push(id); return undefined; }, + }), + upcastPartial({ + error: message => { notifications.push(String(message)); }, + }), + store.add(new class extends NullLogService { + override error(_message: string, error: Error): void { errors.push(error); } + }()), + )); + const click = (label: string) => { + const button = [...widget.element.querySelectorAll('[role="button"]')].find(button => button.textContent === label); + assert.ok(button, label); + button.click(); + }; + return { widget, click, approval, requests, commands, notifications, errors }; + } + + test('does not request file authority merely by presenting an approved external source', () => { + const fixture = createFixture(); + const { model } = createModel(); + fixture.widget.update(model); + assert.deepStrictEqual({ + visible: fixture.widget.element.style.display !== 'none', + explainsSeparation: fixture.widget.element.textContent?.includes('Approving an extension to run does not grant local file access.'), + requests: fixture.requests, + commands: fixture.commands, + }, { visible: true, explainsSeparation: true, requests: [], commands: [] }); + }); + + test('explicit folder approval uses the existing resource-trust dialog and reloads only that native page', async () => { + const fixture = createFixture(); + const { model, loads } = createModel(); + fixture.widget.update(model); + fixture.click('Trust Folder...'); + await fixture.approval.complete(true); + await timeout(0); + assert.deepStrictEqual({ + folders: fixture.requests.map(request => request.uri.toString()), + loads, + commands: fixture.commands, + }, { folders: [URI.file('/canvas-file-trust/outside').toString()], loads: [file.toString()], commands: [] }); + }); + + test('declining folder trust leaves the source blocked', async () => { + const fixture = createFixture(); + const { model, loads } = createModel(); + fixture.widget.update(model); + fixture.click('Trust Folder...'); + await fixture.approval.complete(false); + await timeout(0); + assert.deepStrictEqual({ loads, visible: fixture.widget.element.style.display !== 'none', notifications: fixture.notifications }, { + loads: [], visible: true, notifications: [], + }); + }); + + test('manage trust and manual reload do not request or silently grant folder authority', async () => { + const fixture = createFixture(); + const { model, loads } = createModel(); + fixture.widget.update(model); + fixture.click('Manage Workspace Trust'); + await timeout(0); + fixture.click('Reload'); + await timeout(0); + assert.deepStrictEqual({ commands: fixture.commands, loads, requests: fixture.requests }, { + commands: [MANAGE_TRUST_COMMAND_ID], loads: [file.toString()], requests: [], + }); + }); + + test('a late folder approval cannot reload a replacement owner, even at the same URL', async () => { + const fixture = createFixture(); + const first = createModel(); + const next = createModel(); + fixture.widget.update(first.model); + fixture.click('Trust Folder...'); + fixture.widget.update(next.model); + await fixture.approval.complete(true); + await timeout(0); + assert.deepStrictEqual({ first: first.loads, next: next.loads }, { first: [], next: [] }); + }); + + test('detachment or navigation during approval prevents a late reload', async () => { + for (const detach of [true, false]) { + const fixture = createFixture(); + const model = createModel(); + fixture.widget.update(model.model); + fixture.click('Trust Folder...'); + if (detach) { + fixture.widget.update(undefined); + } else { + model.setState({ url: 'https://example.com/other' }); + fixture.widget.update(model.model); + } + await fixture.approval.complete(true); + await timeout(0); + assert.deepStrictEqual(model.loads, []); + } + }); + + test('trust failures are visible and retry remains available', async () => { + const fixture = createFixture(); + const { model } = createModel(); + fixture.widget.update(model); + fixture.click('Trust Folder...'); + const failure = new Error('Controlled trust persistence failure'); + await fixture.approval.error(failure); + await timeout(0); + assert.deepStrictEqual({ + errors: fixture.errors, notifications: fixture.notifications.length, + enabled: fixture.widget.element.querySelector('[aria-disabled="true"]') === null, + }, { errors: [failure], notifications: 1, enabled: true }); + }); + + test('missing files and other native errors are not treated as folder-trust approval requests', () => { + const fixture = createFixture(); + const model = createModel(); + model.setState({ error: { url: file.toString(), errorCode: -6, errorDescription: 'ERR_FILE_NOT_FOUND' } }); + fixture.widget.update(model.model); + assert.deepStrictEqual({ hidden: fixture.widget.element.style.display, requests: fixture.requests }, { hidden: 'none', requests: [] }); + }); +}); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewFileTrust.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewFileTrust.test.ts new file mode 100644 index 0000000000000..05be0364ff2a4 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewFileTrust.test.ts @@ -0,0 +1,279 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { isCancellationError } from '../../../../../base/common/errors.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IChannel, ProxyChannel } from '../../../../../base/parts/ipc/common/ipc.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { BrowserViewStorageScope, browserZoomDefaultIndex, externalBrowserViewStorageAffinity, IBrowserViewCreateOptions, IBrowserViewInfo, IBrowserViewService, IBrowserViewWindowConfiguration, validateExternalBrowserViewOptions } from '../../../../../platform/browserView/common/browserView.js'; +import { IMainProcessService } from '../../../../../platform/ipc/common/mainProcessService.js'; +import { IWorkspaceContextService, IWorkspaceFolder, WorkbenchState } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { INativeWorkbenchEnvironmentService } from '../../../../services/environment/electron-browser/environmentService.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { BrowserViewModel } from '../../common/browserView.js'; +import { BrowserViewWorkbenchService } from '../../electron-browser/browserViewWorkbenchService.js'; + +suite('BrowserViewWorkbenchService native initialization', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const workspace = URI.file('/canvas-file-trust/workspace'); + const external = URI.file('/canvas-file-trust/outside'); + const resource = URI.parse('test-canvas:/owner/chat/instance'); + + function createViewInfo(id: string, creation: IBrowserViewCreateOptions): IBrowserViewInfo { + return { + id, host: creation.host, owner: creation.owner, + associatedResource: undefined, presentation: creation.presentation, + state: { + url: creation.initialUrl ?? '', title: '', canGoBack: false, canGoForward: false, + loading: false, focused: false, visible: false, isDevToolsOpen: false, + lastScreenshot: undefined, lastFavicon: undefined, lastError: undefined, certificateError: undefined, + storageScope: creation.presentation ? BrowserViewStorageScope.Agent : BrowserViewStorageScope.Global, + storageKeys: {}, permissions: { origins: {} }, + browserZoomIndex: browserZoomDefaultIndex, elementSelectionState: { active: false, options: {} }, + isRemoteSession: false, isAreaSelectionActive: false, device: undefined, audiences: [], + }, + }; + } + + function restoredView(id: string, canvas?: URI, windowId = mainWindow.vscodeWindowId): IBrowserViewInfo { + return createViewInfo(id, { + host: { windowId }, owner: { type: 'user' }, + session: canvas + ? { scope: BrowserViewStorageScope.Agent, affinity: externalBrowserViewStorageAffinity(canvas) } + : { scope: BrowserViewStorageScope.Global }, + presentation: canvas ? { type: 'external', resource: canvas } : undefined, + }); + } + + function createFixture(options: { + configure?: () => Promise; + create?: () => Promise; + existingViews?: readonly IBrowserViewInfo[]; + destroy?: (id: string) => Promise; + } = {}) { + const lifetime = store.add(new DisposableStore()); + const instantiation = workbenchInstantiationService(undefined, lifetime); + const initialized = new DeferredPromise(); + const foldersChanged = lifetime.add(new Emitter()); + const trustChanged = lifetime.add(new Emitter()); + let workspaceTrusted = false; + let trusted: URI[] = []; + const configurations: IBrowserViewWindowConfiguration[] = []; + const creations: IBrowserViewCreateOptions[] = []; + const nativeViews = new Map(options.existingViews?.map(view => [view.id, view])); + const enumeratedWindows: (number | undefined)[] = []; + const destroyRequests: string[] = []; + const destroyed: string[] = []; + const native = upcastPartial({ + onDidCreateBrowserView: Event.None, + getBrowserViews: async windowId => { + enumeratedWindows.push(windowId); + return [...nativeViews.values()].filter(view => windowId === undefined || view.host.windowId === windowId); + }, + updateWindowConfiguration: async (_windowId, configuration) => { + configurations.push(configuration); + await options.configure?.(); + }, + getOrCreateBrowserView: async (id, creation) => { + creations.push(creation); + await options.create?.(); + validateExternalBrowserViewOptions(creation, nativeViews.values()); + const info = createViewInfo(id, creation); + nativeViews.set(id, info); + return info; + }, + destroyBrowserView: async id => { + destroyRequests.push(id); + await options.destroy?.(id); + nativeViews.delete(id); + destroyed.push(id); + }, + }); + const server = ProxyChannel.fromService(native, lifetime); + const channel: IChannel = { + call: (command, args) => server.call(undefined, command, args), + listen: (event, args) => server.listen(undefined, event, args), + }; + instantiation.stub(IMainProcessService, { getChannel: () => channel }); + instantiation.stub(INativeWorkbenchEnvironmentService, { userHome: URI.file('/canvas-file-trust/home') }); + instantiation.stub(IWorkspaceContextService, { + getWorkspace: () => ({ + id: 'canvas-file-trust', + folders: [upcastPartial({ uri: workspace })], + }), + getWorkbenchState: () => WorkbenchState.FOLDER, + onDidChangeWorkspaceFolders: Event.None, + }); + instantiation.stub(IWorkspaceTrustEnablementService, { isWorkspaceTrustEnabled: () => true }); + instantiation.stub(IWorkspaceTrustManagementService, { + workspaceTrustInitialized: initialized.p, + isWorkspaceTrusted: () => workspaceTrusted, + getTrustedUris: () => trusted, + onDidChangeTrust: trustChanged.event, + onDidChangeTrustedFolders: foldersChanged.event, + }); + instantiation.stubInstance(BrowserViewModel, { + onWillDispose: Event.None, onDidClose: Event.None, onDidChangeTitle: Event.None, + onDidChangeFavicon: Event.None, onDidChangeLoadingState: Event.None, onDidNavigate: Event.None, + dispose: () => { }, + }); + const service = lifetime.add(instantiation.createInstance(BrowserViewWorkbenchService)); + lifetime.add(service.onDidChangeBrowserViews(() => { + for (const input of service.getKnownBrowserViews().values()) { + lifetime.add(input); + } + })); + const setTrust = (uris: URI[], workspaceIsTrusted: boolean) => { + trusted = uris; + workspaceTrusted = workspaceIsTrusted; + foldersChanged.fire(); + trustChanged.fire(workspaceIsTrusted); + }; + return { service, initialized, configurations, creations, nativeViews, enumeratedWindows, destroyRequests, destroyed, setTrust }; + } + + test('renderer restoration retires every old external view before replacement without closing ordinary or other-window views', async () => { + const secondResource = resource.with({ path: '/owner/chat/second' }); + const firstDestroyed = new DeferredPromise(); + const secondDestroyed = new DeferredPromise(); + const fixture = createFixture({ + existingViews: [ + restoredView('old-first', resource), + restoredView('ordinary'), + restoredView('old-second', secondResource), + restoredView('other-window', resource.with({ path: '/other-owner/chat/instance' }), mainWindow.vscodeWindowId + 1), + ], + destroy: id => id === 'old-first' ? firstDestroyed.p : secondDestroyed.p, + }); + await fixture.initialized.complete(); + const replacements = Promise.allSettled([ + fixture.service.getOrCreateExternalBrowserView('new-first', resource, 'http://localhost/first'), + fixture.service.getOrCreateExternalBrowserView('new-second', secondResource, 'http://localhost/second'), + ]); + await timeout(0); + const beforeCleanup = fixture.creations.length; + await firstDestroyed.complete(); + await timeout(0); + const afterFirstCleanup = fixture.creations.length; + await secondDestroyed.complete(); + const outcomes = (await replacements).map(result => result.status); + + assert.deepStrictEqual({ + beforeCleanup, afterFirstCleanup, outcomes, + enumeratedWindows: fixture.enumeratedWindows, + destroyRequests: fixture.destroyRequests, + destroyed: fixture.destroyed, + remaining: [...fixture.nativeViews.keys()].sort(), + ordinaryEditors: [...fixture.service.getKnownBrowserViews().keys()], + }, { + beforeCleanup: 0, afterFirstCleanup: 0, outcomes: ['fulfilled', 'fulfilled'], + enumeratedWindows: [mainWindow.vscodeWindowId], + destroyRequests: ['old-first', 'old-second'], + destroyed: ['old-first', 'old-second'], + remaining: ['new-first', 'new-second', 'ordinary', 'other-window'], + ordinaryEditors: ['ordinary'], + }); + }); + + test('failed restoration cleanup blocks replacement but still restores ordinary browser inputs', async () => { + const failure = new Error('Controlled native cleanup failure'); + const fixture = createFixture({ + existingViews: [restoredView('old', resource), restoredView('ordinary')], + destroy: async () => { throw failure; }, + }); + await fixture.initialized.complete(); + await assert.rejects(fixture.service.getOrCreateExternalBrowserView('new', resource, 'http://localhost/'), error => error === failure); + assert.deepStrictEqual({ + creations: fixture.creations, + destroyRequests: fixture.destroyRequests, + remaining: [...fixture.nativeViews.keys()], + ordinaryEditors: [...fixture.service.getKnownBrowserViews().keys()], + }, { + creations: [], destroyRequests: ['old'], remaining: ['old', 'ordinary'], ordinaryEditors: ['ordinary'], + }); + }); + + test('waits for initialized file authority and its native acknowledgement before external creation', async () => { + const configured = new DeferredPromise(); + const fixture = createFixture({ configure: () => configured.p }); + const pending = fixture.service.getOrCreateExternalBrowserView('external-file', resource, URI.joinPath(external, 'index.html').toString()); + await timeout(0); + const beforeInitialization = fixture.creations.length; + fixture.setTrust([external], true); + await fixture.initialized.complete(); + await timeout(0); + const beforeAcknowledgement = fixture.creations.length; + await configured.complete(); + await pending; + const configuration = fixture.configurations.at(-1)!; + const creation = fixture.creations[0]; + assert.deepStrictEqual({ + beforeInitialization, beforeAcknowledgement, + roots: [workspace, external].map(root => configuration.trustedFileRoots.includes(root.fsPath)), + trustAllFiles: configuration.trustAllFiles, + owner: creation.owner, audiences: creation.initialAudiences, presentation: creation.presentation, + session: creation.session, + ordinaryEditors: fixture.service.getKnownBrowserViews().size, + }, { + beforeInitialization: 0, beforeAcknowledgement: 0, roots: [true, true], trustAllFiles: false, + owner: { type: 'user' }, audiences: [], presentation: { type: 'external', resource }, + session: { scope: BrowserViewStorageScope.Agent, affinity: externalBrowserViewStorageAffinity(resource) }, + ordinaryEditors: 0, + }); + }); + + test('explicit outside-folder trust is independent of current-workspace trust and is revoked live', async () => { + const fixture = createFixture(); + fixture.setTrust([external], false); + await fixture.initialized.complete(); + await fixture.service.getOrCreateExternalBrowserView('outside-file', resource, URI.joinPath(external, 'index.html').toString()); + const granted = fixture.configurations.at(-1)!; + fixture.setTrust([], false); + await timeout(0); + const revoked = fixture.configurations.at(-1)!; + assert.deepStrictEqual({ + granted: [workspace, external].map(root => granted.trustedFileRoots.includes(root.fsPath)), + revoked: [workspace, external].map(root => revoked.trustedFileRoots.includes(root.fsPath)), + all: [granted.trustAllFiles, revoked.trustAllFiles], + creations: fixture.creations.length, + }, { granted: [false, true], revoked: [false, false], all: [false, false], creations: 1 }); + }); + + test('failed native authority synchronization cannot create an external page', async () => { + const failure = new Error('Controlled native configuration failure'); + const fixture = createFixture({ configure: async () => { throw failure; } }); + await fixture.initialized.complete(); + await assert.rejects(fixture.service.getOrCreateExternalBrowserView('file', resource, 'file:///canvas-file-trust/outside/index.html'), error => error === failure); + assert.deepStrictEqual(fixture.creations, []); + }); + + test('disposal while awaiting trust initialization prevents native allocation', async () => { + const fixture = createFixture(); + const pending = fixture.service.getOrCreateExternalBrowserView('file', resource, 'file:///canvas-file-trust/outside/index.html'); + fixture.service.dispose(); + await fixture.initialized.complete(); + await assert.rejects(pending, isCancellationError); + assert.deepStrictEqual(fixture.creations, []); + }); + + test('native allocation completing after disposal is released instead of becoming a presentation', async () => { + const created = new DeferredPromise(); + const fixture = createFixture({ create: () => created.p }); + await fixture.initialized.complete(); + const pending = fixture.service.getOrCreateExternalBrowserView('late-file', resource, 'file:///canvas-file-trust/outside/index.html'); + await timeout(0); + fixture.service.dispose(); + await created.complete(); + await assert.rejects(pending, isCancellationError); + assert.deepStrictEqual({ created: fixture.creations.length, destroyed: fixture.destroyed }, { created: 1, destroyed: ['late-file'] }); + }); +}); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewModel.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewModel.test.ts index d8448ff3270f1..c9d81a27a16d8 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewModel.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewModel.test.ts @@ -97,6 +97,7 @@ suite('BrowserViewModel', () => { undefined, { ...initialState, url }, browserViewService, + undefined, browserViewWorkbenchService, NullTelemetryService, dialogService, diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewPermissions.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewPermissions.test.ts new file mode 100644 index 0000000000000..36645ec0f0266 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/browserViewPermissions.test.ts @@ -0,0 +1,278 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { PermissionCategory } from '../../../../../platform/browserView/common/browserPermissions.js'; +import type { IBrowserViewDeviceRequest, IBrowserViewNavigationEvent, IBrowserViewPermissionRequestEvent } from '../../../../../platform/browserView/common/browserView.js'; +import type { IPrompt, IPromptResult, IPromptResultWithCancel, IPromptWithCustomCancel, IPromptWithDefaultCancel } from '../../../../../platform/dialogs/common/dialogs.js'; +import { TestDialogService } from '../../../../../platform/dialogs/test/common/testDialogService.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import type { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { type IQuickInputHideEvent, type IQuickInputService, type IQuickPick, type IQuickPickDidAcceptEvent, type IQuickPickItem, QuickInputHideReason } from '../../../../../platform/quickinput/common/quickInput.js'; +import type { IBrowserViewModel } from '../../common/browserView.js'; +import { BrowserViewPermissionHandler } from '../../electron-browser/browserViewPermissions.js'; + +class DeferredDialogService extends TestDialogService { + readonly prompts: { options: IPrompt; answer: DeferredPromise }[] = []; + + override prompt(prompt: IPromptWithCustomCancel): Promise>; + override prompt(prompt: IPromptWithDefaultCancel): Promise>; + override prompt(prompt: IPrompt): Promise>; + override async prompt(prompt: IPrompt): Promise> { + const answer = new DeferredPromise(); + this.prompts.push({ options: prompt, answer }); + const index = await answer.p; + const button = index === undefined + ? typeof prompt.cancelButton === 'object' ? prompt.cancelButton : undefined + : prompt.buttons?.[index]; + return { result: await button?.run({}) }; + } +} + +suite('BrowserViewPermissionHandler', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + const origin = 'https://canvas.example'; + const device: IBrowserViewDeviceRequest = { + requestId: 'device-request', deviceType: 'usb', + devices: [{ deviceId: 'one', label: 'First device' }], + }; + + function createFixture(failure?: Error) { + const permission = store.add(new Emitter()); + const willNavigate = store.add(new Emitter()); + const navigate = store.add(new Emitter()); + const disposing = store.add(new Emitter()); + const writes: Parameters[] = []; + const selections: Parameters[] = []; + const errors: Error[] = []; + const notifications: string[] = []; + let selectionResponse: Promise | undefined; + const model = upcastPartial({ + id: 'browser-view', url: origin, + onDidRequestPermission: permission.event, + onWillNavigate: willNavigate.event, + onDidNavigate: navigate.event, + onWillDispose: disposing.event, + setPermissions: async (...args) => { + writes.push(args); + if (failure) { + throw failure; + } + }, + selectDevice: async (...args) => { + selections.push(args); + if (failure) { + throw failure; + } + await selectionResponse; + }, + }); + const dialogs = new DeferredDialogService(); + const pickers: { + select(index: number): void; + hide(): void; + snapshot(): { visible: boolean; labels: string[]; listening: boolean[] }; + }[] = []; + const quickInput = new class extends mock() { + override createQuickPick(options: { useSeparators: true }): IQuickPick; + override createQuickPick(options?: { useSeparators: boolean }): IQuickPick; + override createQuickPick(options?: { useSeparators: boolean }): IQuickPick | IQuickPick { + assert.notStrictEqual(options?.useSeparators, true); + const lifetime = store.add(new DisposableStore()); + const accept = lifetime.add(new Emitter()); + const hide = lifetime.add(new Emitter()); + let visible = false; + const picker = upcastPartial>({ + items: [], activeItems: [], selectedItems: [], + onDidAccept: accept.event, onDidHide: hide.event, + show: () => { visible = true; }, + hide: () => { visible = false; hide.fire({ reason: QuickInputHideReason.Gesture }); }, + dispose: () => { picker.hide(); lifetime.dispose(); }, + }); + pickers.push({ + select: index => { + const item = picker.items[index]; + assert.ok(item); + picker.selectedItems = [item]; + accept.fire({ inBackground: false }); + }, + hide: () => picker.hide(), + snapshot: () => ({ visible, labels: picker.items.map(item => item.label), listening: [accept.hasListeners(), hide.hasListeners()] }), + }); + return picker; + } + }(); + const handler = store.add(new BrowserViewPermissionHandler( + model, quickInput, dialogs, + upcastPartial({ error: message => notifications.push(String(message)) }), + store.add(new class extends NullLogService { + override error(_message: string, error: Error): void { errors.push(error); } + }()), + )); + return { + handler, dialogs, pickers, writes, selections, errors, notifications, willNavigate, navigate, disposing, + requestPermission: () => permission.fire({ origin, category: PermissionCategory.Clipboard }), + requestDevice: (request = device) => permission.fire({ origin, category: PermissionCategory.Devices, device: request }), + listeners: () => [permission.hasListeners(), willNavigate.hasListeners(), navigate.hasListeners(), disposing.hasListeners()], + deferSelection: (response: Promise) => { selectionResponse = response; }, + }; + } + + test('allow, block and explicit cancellation use the captured native permission write path', async () => { + const fixture = createFixture(); + for (const index of [0, 1, undefined]) { + fixture.requestPermission(); + await fixture.dialogs.prompts.at(-1)!.answer.complete(index); + await timeout(0); + } + assert.deepStrictEqual({ + writes: fixture.writes, + prompts: fixture.dialogs.prompts.map(({ options }) => ({ + custom: options.custom, cancellable: !!options.token, + origin: options.message.includes('canvas.example'), category: options.message.includes('Clipboard'), + })), + }, { + writes: ['allow', 'deny', null].map(state => [origin, [{ category: PermissionCategory.Clipboard, state }]]), + prompts: Array.from({ length: 3 }, () => ({ custom: true, cancellable: true, origin: true, category: true })), + }); + }); + + test('detaching cancels the pending UI and cannot apply a late grant to either owner', async () => { + const original = createFixture(); + original.requestPermission(); + original.handler.dispose(); + const replacement = createFixture(); + await original.dialogs.prompts[0].answer.complete(0); + await timeout(0); + assert.deepStrictEqual({ + cancelled: original.dialogs.prompts[0].options.token?.isCancellationRequested, + original: original.writes, replacement: replacement.writes, listeners: original.listeners(), + }, { cancelled: true, original: [[origin, [{ category: PermissionCategory.Clipboard, state: null }]]], replacement: [], listeners: [false, false, false, false] }); + }); + + test('navigation coalesces duplicate prompts and an old completion cannot cancel the new request', async () => { + const fixture = createFixture(); + fixture.requestPermission(); + fixture.requestPermission(); + fixture.willNavigate.fire(`${origin}/next`); + fixture.navigate.fire(upcastPartial({ url: `${origin}/next` })); + fixture.requestPermission(); + await fixture.dialogs.prompts[0].answer.complete(0); + await timeout(0); + const newPromptCancelled = fixture.dialogs.prompts[1].options.token?.isCancellationRequested; + await fixture.dialogs.prompts[1].answer.complete(1); + await timeout(0); + assert.deepStrictEqual({ count: fixture.dialogs.prompts.length, newPromptCancelled, writes: fixture.writes }, { + count: 2, newPromptCancelled: false, + writes: [null, 'deny'].map(state => [origin, [{ category: PermissionCategory.Clipboard, state }]]), + }); + }); + + test('model disposal dismisses pending UI without writing to a destroyed native page', async () => { + const fixture = createFixture(); + fixture.requestPermission(); + fixture.requestDevice(); + fixture.disposing.fire(); + await fixture.dialogs.prompts[0].answer.complete(0); + await timeout(0); + assert.deepStrictEqual({ + writes: fixture.writes, selections: fixture.selections, + cancelled: fixture.dialogs.prompts[0].options.token?.isCancellationRequested, + picker: fixture.pickers[0].snapshot(), listeners: fixture.listeners(), + }, { + writes: [], selections: [], cancelled: true, + picker: { visible: false, labels: ['First device'], listening: [false, false] }, listeners: [false, false, false, false], + }); + }); + + test('one live device chooser updates and submits one selection while ignoring updates awaiting its acknowledgement', async () => { + const fixture = createFixture(); + const selection = new DeferredPromise(); + fixture.deferSelection(selection.p); + fixture.requestDevice(); + const updated: IBrowserViewDeviceRequest = { ...device, devices: [...device.devices, { deviceId: 'two', label: 'Second device' }] }; + fixture.requestDevice(updated); + fixture.pickers[0].select(1); + fixture.requestDevice(updated); + const beforeAcknowledgement = fixture.pickers.map(picker => picker.snapshot()); + await selection.complete(); + await timeout(0); + fixture.handler.dispose(); + assert.deepStrictEqual({ beforeAcknowledgement, selections: fixture.selections, writes: fixture.writes, listeners: fixture.listeners() }, { + beforeAcknowledgement: [{ visible: false, labels: ['First device', 'Second device'], listening: [false, false] }], + selections: [['device-request', 'two']], writes: [], listeners: [false, false, false, false], + }); + }); + + test('navigation cancellation retains the chooser identity until its acknowledgement', async () => { + for (const event of ['willNavigate', 'didNavigate']) { + const fixture = createFixture(); + const cancellation = new DeferredPromise(); + fixture.deferSelection(cancellation.p); + fixture.requestDevice(); + if (event === 'willNavigate') { + fixture.willNavigate.fire('https://other.example'); + } else { + fixture.navigate.fire(upcastPartial({ url: 'https://other.example' })); + } + fixture.requestDevice({ ...device, devices: [{ deviceId: 'late', label: 'Late device discovery' }] }); + const beforeAcknowledgement = fixture.pickers.map(picker => picker.snapshot()); + await cancellation.complete(); + await timeout(0); + fixture.handler.dispose(); + assert.deepStrictEqual({ beforeAcknowledgement, selections: fixture.selections, listeners: fixture.listeners() }, { + beforeAcknowledgement: [{ visible: false, labels: ['First device'], listening: [false, false] }], + selections: [['device-request', null]], listeners: [false, false, false, false], + }); + } + }); + + test('hiding, navigating or detaching a device chooser sends exactly one cancellation', async () => { + for (const reason of ['hide', 'navigate', 'detach']) { + const fixture = createFixture(); + fixture.requestDevice(); + if (reason === 'hide') { + fixture.pickers[0].hide(); + } else if (reason === 'navigate') { + fixture.navigate.fire(upcastPartial({ url: 'https://other.example' })); + } else { + fixture.handler.dispose(); + } + fixture.pickers[0].select(0); + await timeout(0); + fixture.handler.dispose(); + assert.deepStrictEqual({ selections: fixture.selections, picker: fixture.pickers[0].snapshot(), listeners: fixture.listeners() }, { + selections: [['device-request', null]], + picker: { visible: false, labels: ['First device'], listening: [false, false] }, listeners: [false, false, false, false], + }); + } + }); + + test('prompt and native write failures are visible and preserve the original error without retries', async () => { + for (const kind of ['prompt', 'permission', 'device']) { + const failure = new Error('Controlled native permission write failure'); + const fixture = createFixture(kind === 'prompt' ? undefined : failure); + if (kind === 'prompt') { + fixture.requestPermission(); + await fixture.dialogs.prompts[0].answer.error(failure); + } else if (kind === 'permission') { + fixture.requestPermission(); + await fixture.dialogs.prompts[0].answer.complete(0); + } else { + fixture.requestDevice(); + fixture.pickers[0].select(0); + } + await timeout(0); + assert.deepStrictEqual({ attempts: fixture.writes.length + fixture.selections.length, errors: fixture.errors, notifications: fixture.notifications.length }, { + attempts: 1, errors: [failure], notifications: 1, + }); + } + }); +}); diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts index 7ce305c907226..7dca6d1ad1f31 100644 --- a/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/overlayManager.test.ts @@ -62,6 +62,24 @@ suite('BrowserOverlayManager', () => { assert.deepStrictEqual(overlays, []); }); + test('detects accessible content outside its zero-sized context view anchor', () => { + const browserContainer = addElement('browser-container', { + position: 'absolute', left: '0px', top: '0px', width: '300px', height: '300px' + }); + const contextView = addElement('context-view', { + position: 'absolute', left: '20px', top: '20px', width: '0px', height: '0px', zIndex: '2575' + }); + const accessibleView = addElement('accessible-view', { + position: 'absolute', left: '0px', top: '0px', width: '400px', height: '200px' + }, contextView); + + const visible = manager.getOverlappingOverlays(browserContainer).map(overlay => overlay.type); + accessibleView.style.display = 'none'; + const hidden = manager.getOverlappingOverlays(browserContainer).map(overlay => overlay.type); + + assert.deepStrictEqual({ visible, hidden }, { visible: [BrowserOverlayType.Unknown], hidden: [] }); + }); + test('detects an overlay beneath detached webview content', () => { const browserContainer = addElement('browser-container', { position: 'absolute', left: '0px', top: '0px', width: '300px', height: '300px' diff --git a/src/vs/workbench/contrib/browserView/test/electron-browser/webContentsViewHost.test.ts b/src/vs/workbench/contrib/browserView/test/electron-browser/webContentsViewHost.test.ts new file mode 100644 index 0000000000000..2adb7c2012ec1 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/electron-browser/webContentsViewHost.test.ts @@ -0,0 +1,160 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../../base/common/async.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { toDisposable } from '../../../../../base/common/lifecycle.js'; +import { upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IWorkspaceTrustRequestService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { IBrowserViewLoadingEvent } from '../../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewModel } from '../../common/browserView.js'; +import { WebContentsViewHost } from '../../electron-browser/webContentsViewHost.js'; + +suite('WebContentsViewHost', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createFixture(hide: () => Promise = async () => { }) { + const errors: Parameters[] = []; + const logService = store.add(new class extends NullLogService { + override error(...args: Parameters): void { + errors.push(args); + } + }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ILogService, logService); + instantiationService.stub(IWorkspaceTrustRequestService, {}); + instantiationService.stub(ICommandService, {}); + instantiationService.stub(INotificationService, {}); + const host = store.add(new WebContentsViewHost(mainWindow, () => { }, logService, upcastPartial({}), instantiationService)); + const onWillDispose = store.add(new Emitter()); + const visibility: boolean[] = []; + let disposals = 0; + const model = upcastPartial({ + url: '', + visible: false, + onDidChangeVisibility: Event.None, + onDidKeyCommand: Event.None, + onDidNavigate: Event.None, + onDidChangeLoadingState: Event.None, + onWillDispose: onWillDispose.event, + setVisible: async visible => { + visibility.push(visible); + await hide(); + }, + dispose: () => { disposals++; }, + }); + host.setModel(model); + return { host, model, onWillDispose, errors, visibility, disposals: () => disposals }; + } + + test('reports a rejected detach hide once without retrying or disposing the reusable model', async () => { + const failure = new Error('Controlled IPC failure'); + const fixture = createFixture(async () => { throw failure; }); + fixture.host.setModel(undefined); + await timeout(0); + fixture.host.dispose(); + assert.deepStrictEqual({ + visibility: fixture.visibility, + errors: fixture.errors, + disposals: fixture.disposals(), + }, { + visibility: [false], + errors: [['WebContentsViewHost: Failed to hide detached browser view', failure]], + disposals: 0, + }); + }); + + test('model disposal detaches without sending hide IPC to native content being destroyed', () => { + const fixture = createFixture(); + fixture.onWillDispose.fire(); + fixture.host.dispose(); + assert.deepStrictEqual({ + visibility: fixture.visibility, + errors: fixture.errors, + listening: fixture.onWillDispose.hasListeners(), + }, { visibility: [], errors: [], listening: false }); + }); + + test('ordinary detachment and host disposal leave the browser model reusable', async () => { + const fixture = createFixture(); + fixture.host.setModel(undefined); + fixture.host.setModel(fixture.model); + fixture.host.dispose(); + await timeout(0); + assert.deepStrictEqual({ + visibility: fixture.visibility, + errors: fixture.errors, + disposals: fixture.disposals(), + }, { visibility: [false, false], errors: [], disposals: 0 }); + }); + + test('a late hide failure is still reported after the host reattaches the model', async () => { + const hide = new DeferredPromise(); + const fixture = createFixture(() => hide.p); + fixture.host.setModel(undefined); + fixture.host.setModel(fixture.model); + const failure = new Error('Controlled late IPC failure'); + await hide.error(failure); + await timeout(0); + fixture.onWillDispose.fire(); + assert.deepStrictEqual({ + visibility: fixture.visibility, + errors: fixture.errors, + disposals: fixture.disposals(), + }, { + visibility: [false], + errors: [['WebContentsViewHost: Failed to hide detached browser view', failure]], + disposals: 0, + }); + }); + + test('revoked file content replaces cached imagery with focusable trust recovery, including a late screenshot', async () => { + const fixture = createFixture(); + const container = mainWindow.document.createElement('div'); + mainWindow.document.body.appendChild(container); + store.add(toDisposable(() => container.remove())); + fixture.host.onContainerCreated(container); + fixture.host.setVisible(true); + const loading = store.add(new Emitter()); + const screenshot = new DeferredPromise(); + let loadingState: IBrowserViewLoadingEvent = { loading: false }; + let visible = true; + const model = upcastPartial({ + url: 'file:///canvas-file-trust/revoked/index.html', + get error() { return loadingState.error; }, + get visible() { return visible; }, + loading: false, + screenshot: VSBuffer.fromString('previous-page'), + onDidChangeVisibility: Event.None, + onDidKeyCommand: Event.None, + onDidNavigate: Event.None, + onDidChangeLoadingState: loading.event, + onWillDispose: Event.None, + captureScreenshot: () => screenshot.p, + setVisible: async value => { visible = value; }, + }); + fixture.host.setModel(model); + loadingState = { loading: false, error: { url: model.url, errorCode: -2, errorDescription: 'ERR_FAILED', fileAccessDenied: true } }; + loading.fire(loadingState); + await screenshot.complete(VSBuffer.fromString('late-revoked-page')); + await timeout(0); + assert.deepStrictEqual({ + screenshot: fixture.host.screenshotElement.style.backgroundImage, + screenshotDisplay: fixture.host.screenshotElement.style.display, + focused: fixture.host.tryFocus(), + focusLabel: mainWindow.document.activeElement?.textContent, + errors: fixture.errors, + }, { screenshot: '', screenshotDisplay: 'none', focused: true, focusLabel: 'Trust Folder...', errors: [] }); + }); +}); diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 96822835104f5..cf84a91a21c9f 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -96,6 +96,13 @@ class MockAgentConnection implements IAgentConnection { async listAutomationTriggerDefinitions(_params: ListAutomationTriggerDefinitionsParams): Promise { return { items: [] }; } async runAutomation(_params: RunAutomationParams): Promise { throw new Error('Not implemented'); } async fetchAutomationRuns(_params: FetchAutomationRunsParams): Promise { return {}; } + async initializeCanvasChat(): ReturnType { throw new Error('Not implemented'); } + async listCanvasTypes(): ReturnType { throw new Error('Not implemented'); } + async openCanvas(): ReturnType { throw new Error('Not implemented'); } + async resolveCanvasSource(): ReturnType { throw new Error('Not implemented'); } + async invokeCanvasAction(): ReturnType { throw new Error('Not implemented'); } + async restartCanvasProvider(): ReturnType { throw new Error('Not implemented'); } + async closeCanvas(): ReturnType { throw new Error('Not implemented'); } async getCompletionTriggerCharacters(): Promise { return []; } async disposeSession(_session: URI): Promise { } async createChat(_session: URI, _chat: URI): Promise { } diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index 5e1fe4e23c21b..91cbc3c6c2876 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -10,6 +10,8 @@ // connection (and AHP handshake) happens asynchronously in the background. import { Emitter, Event } from '../../../../base/common/event.js'; +import type { CancellationToken } from '../../../../base/common/cancellation.js'; +import type { InitializeCanvasChatParams } from '../../../../platform/agentHost/common/agentHostExtensionProtocol.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { autorun, IObservable, ISettableObservable, observableValue, constObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; @@ -23,6 +25,7 @@ import { AgentHostClientState, AgentHostProtocolClient } from '../../../../platf import type { IActiveSubscriptionInfo, IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; import type { CompletionsParams, CompletionsResult, ContentEncoding, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../../../platform/agentHost/common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../../../../platform/agentHost/common/state/protocol/channels-changeset/commands.js'; +import type { CloseCanvasParams, InvokeCanvasActionParams, InvokeCanvasActionResult, ListCanvasTypesParams, ListCanvasTypesResult, OpenCanvasParams, OpenCanvasResult, ResolveCanvasSourceParams, ResolveCanvasSourceResult, RestartCanvasProviderParams } from '../../../../platform/agentHost/common/state/protocol/channels-canvas/commands.js'; import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../../../../platform/agentHost/common/state/protocol/channels-automation/commands.js'; import type { ActionEnvelope, ChatAction, ClientAnnotationsAction, ClientAutomationAction, ClientAutomationRunAction, ClientChangesetAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import type { IRemoteWatchHandle } from '../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; @@ -309,6 +312,34 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().invokeChangesetOperation(params); } + listCanvasTypes(params: ListCanvasTypesParams): Promise { + return this._requireClient().listCanvasTypes(params); + } + + initializeCanvasChat(params: InitializeCanvasChatParams, token?: CancellationToken): Promise { + return this._requireClient().initializeCanvasChat(params, token); + } + + openCanvas(params: OpenCanvasParams): Promise { + return this._requireClient().openCanvas(params); + } + + resolveCanvasSource(params: ResolveCanvasSourceParams): Promise { + return this._requireClient().resolveCanvasSource(params); + } + + invokeCanvasAction(params: InvokeCanvasActionParams): Promise { + return this._requireClient().invokeCanvasAction(params); + } + + restartCanvasProvider(params: RestartCanvasProviderParams): Promise { + return this._requireClient().restartCanvasProvider(params); + } + + closeCanvas(params: CloseCanvasParams): Promise { + return this._requireClient().closeCanvas(params); + } + handleMcpRequest(channel: string, method: string, params: Record | undefined): Promise { return this._requireClient().handleMcpRequest(channel, method, params); } diff --git a/test/smoke/src/areas/browserView/browserView.test.ts b/test/smoke/src/areas/browserView/browserView.test.ts index 0a10b53300663..9f775e497f54d 100644 --- a/test/smoke/src/areas/browserView/browserView.test.ts +++ b/test/smoke/src/areas/browserView/browserView.test.ts @@ -184,6 +184,18 @@ export function setup(logger: Logger): void { await input.pressSequentially('x'); assert.strictEqual(await input.inputValue(), 'x'); + await input.press(`${modifier}+Alt+c`); + await browserPage.locator('[data-vscode-pick-host]').waitFor({ state: 'attached' }); + await workbenchPage.locator('.browser-root [aria-label^="Comment on Elements"][aria-pressed="true"]').waitFor(); + await browserPage.keyboard.press('Alt+F1'); + const accessibilityHelp = workbenchPage.locator('.accessible-view'); + await accessibilityHelp.waitFor(); + await accessibilityHelp.locator('.view-line', { hasText: 'You are in Integrated Browser element commenting mode.' }).waitFor(); + await workbenchPage.keyboard.press('Escape'); + await accessibilityHelp.waitFor({ state: 'hidden' }); + await browserPage.keyboard.press('Escape'); + await browserPage.locator('[data-vscode-pick-host]').waitFor({ state: 'detached' }); + await input.press(`${modifier}+Shift+p`); await workbenchPage.locator('.quick-input-widget:visible input[placeholder*="Type the name of a command"]').waitFor(); await workbenchPage.keyboard.press('Escape');