diff --git a/.github/workflows/check-clean-git-state.sh b/.github/workflows/check-clean-git-state.sh index cd09d4db30c1c..e52d45648dd36 100755 --- a/.github/workflows/check-clean-git-state.sh +++ b/.github/workflows/check-clean-git-state.sh @@ -2,5 +2,9 @@ R=`git status --porcelain | wc -l` if [ "$R" -ne "0" ]; then echo "The git repo is not clean after compiling the /build/ folder. Did you forget to commit .js output for .ts files?"; git status --porcelain + echo "\nUnstaged diff:"; + git --no-pager diff + echo "\nStaged diff:"; + git --no-pager diff --cached exit 1; fi diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9b367a0e8fa6b..521964b2e3393 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -159,7 +159,7 @@ jobs: copilot-check-test-cache: name: Copilot - Check Test Cache - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 permissions: contents: read pull-requests: read @@ -205,7 +205,7 @@ jobs: copilot-check-telemetry: name: Copilot - Check Telemetry - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -224,7 +224,7 @@ jobs: copilot-linux-tests: name: Copilot - Test (Linux) - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64 ] + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -252,6 +252,11 @@ jobs: - name: Install setuptools run: pip install setuptools + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y xvfb libgtk-3-0 libgbm1 + - name: Restore build cache uses: actions/cache/restore@v4 id: build-cache @@ -332,7 +337,7 @@ jobs: copilot-windows-tests: name: Copilot - Test (Windows) - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64 ] + runs-on: windows-2022 permissions: contents: read steps: diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index bf52531b1d005..e562f6c0845f1 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -46,6 +46,9 @@ jobs: run: npm ci working-directory: build/rspack + - name: Copy codicons + run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf + - name: Transpile source run: npm run transpile-client @@ -62,45 +65,17 @@ jobs: test/componentFixtures/.screenshots/current/manifest.json \ test/componentFixtures/blocks-ci-screenshots.md \ https://hediet-screenshots.azurewebsites.net \ - --json \ - > /tmp/blocks-ci-diff.json 2>/tmp/blocks-ci-stderr.txt \ + > /tmp/blocks-ci-updated.md 2>/tmp/blocks-ci-stderr.txt \ && echo "match=true" >> "$GITHUB_OUTPUT" \ || { echo "match=false" >> "$GITHUB_OUTPUT" cat /tmp/blocks-ci-stderr.txt >&2 - DIFF=$(cat /tmp/blocks-ci-diff.json) - echo "diff<> "$GITHUB_OUTPUT" - echo "$DIFF" >> "$GITHUB_OUTPUT" + CONTENT=$(cat /tmp/blocks-ci-updated.md) + echo "content<> "$GITHUB_OUTPUT" + echo "$CONTENT" >> "$GITHUB_OUTPUT" echo "BLOCKS_CI_EOF" >> "$GITHUB_OUTPUT" } - - name: Suggest blocks-ci screenshot update - if: github.event_name == 'pull_request' && steps.blocks-ci.outputs.match == 'false' - uses: actions/github-script@v7 - with: - script: | - const diff = JSON.parse(process.env.DIFF_JSON); - const filePath = 'test/componentFixtures/blocks-ci-screenshots.md'; - const marker = ''; - - await github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - commit_id: context.payload.pull_request.head.sha, - event: 'COMMENT', - body: `${marker}\n### blocks-ci screenshots changed\n\nApply the suggestion below to update the committed screenshots.`, - comments: [{ - path: filePath, - start_line: diff.startLine < diff.endLine ? diff.startLine : undefined, - line: diff.endLine, - side: 'RIGHT', - body: `${marker}\nScreenshots for \`blocks-ci\` fixtures have changed. Apply this suggestion to accept:\n\n\`\`\`suggestion\n${diff.replacement}\n\`\`\``, - }], - }); - env: - DIFF_JSON: ${{ steps.blocks-ci.outputs.diff }} - - name: Upload screenshots uses: actions/upload-artifact@v7 with: @@ -148,12 +123,24 @@ jobs: SCREENSHOT_SERVICE_TOKEN: ${{ steps.oidc.outputs.token }} - name: Post PR comment - if: github.event_name == 'pull_request' && steps.diff.outputs.has_changes == 'true' + if: github.event_name == 'pull_request' && (steps.diff.outputs.has_changes == 'true' || steps.blocks-ci.outputs.match == 'false') uses: actions/github-script@v7 with: script: | const marker = ''; - const body = process.env.COMMENT_BODY; + let body = process.env.COMMENT_BODY || ''; + const blocksCiContent = process.env.BLOCKS_CI_CONTENT; + + if (blocksCiContent) { + if (body) { body += '\n\n---\n\n'; } + body += '### blocks-ci screenshots changed\n\n'; + body += 'Replace the contents of `test/componentFixtures/blocks-ci-screenshots.md` with:\n\n'; + body += '
\nUpdated blocks-ci-screenshots.md\n\n'; + body += '```md\n' + blocksCiContent + '\n```\n\n'; + body += '
'; + } + + body = marker + '\n' + body; const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, @@ -180,11 +167,12 @@ jobs: } env: COMMENT_BODY: ${{ steps.diff.outputs.body }} + BLOCKS_CI_CONTENT: ${{ steps.blocks-ci.outputs.content }} - name: Fail if blocks-ci hashes changed if: steps.blocks-ci.outputs.match == 'false' run: | - echo "::error::blocks-ci screenshot hashes do not match committed file. See PR review suggestion to update." + echo "::error::blocks-ci screenshot hashes do not match committed file. See PR comment for updated content." exit 1 # - name: Compare screenshots diff --git a/.vscode/launch.json b/.vscode/launch.json index bcb6a98c61fda..7d91e225da011 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,7 +20,8 @@ "port": 5870, "outFiles": [ "${workspaceFolder}/out/**/*.js", - "${workspaceFolder}/extensions/*/out/**/*.js" + "${workspaceFolder}/extensions/*/out/**/*.js", + "${workspaceFolder}/extensions/copilot/dist/**/*.js" ] }, { diff --git a/build/.moduleignore b/build/.moduleignore index 7d50f0ee5512b..f83624f893c37 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -99,6 +99,20 @@ kerberos/node_modules/** cpu-features/** +# utf-8-validate and bufferutil are native modules pulled in by the websocket +# package. They include prebuilds for multiple platforms which break cross-platform +# bundling (rcedit fails on non-PE .node files). Both modules have pure JS fallbacks +# that are sufficient for Node.js >= 18.14.0, so we strip all native artifacts. +utf-8-validate/binding.gyp +utf-8-validate/build/** +utf-8-validate/prebuilds/** +utf-8-validate/src/** + +bufferutil/binding.gyp +bufferutil/build/** +bufferutil/prebuilds/** +bufferutil/src/** + ssh2/lib/protocol/crypto/binding.gyp ssh2/lib/protocol/crypto/build/** ssh2/lib/protocol/crypto/src/** diff --git a/build/azure-pipelines/common/downloadCopilotVsix.ts b/build/azure-pipelines/common/downloadCopilotVsix.ts index 8b8f005beea1c..b6df6ba151e45 100644 --- a/build/azure-pipelines/common/downloadCopilotVsix.ts +++ b/build/azure-pipelines/common/downloadCopilotVsix.ts @@ -52,7 +52,7 @@ async function checkCopilotJobFailed(): Promise { r => r.type === 'Job' && r.name === COPILOT_JOB_NAME ); - if (copilotJob && copilotJob.state === 'completed' && copilotJob.result !== 'succeeded') { + if (copilotJob && copilotJob.state === 'completed' && copilotJob.result !== 'succeeded' && copilotJob.result !== 'succeededWithIssues') { return true; } } catch (err) { diff --git a/build/copilot-migrate-pr.ts b/build/copilot-migrate-pr.ts new file mode 100644 index 0000000000000..6a6076aadecf4 --- /dev/null +++ b/build/copilot-migrate-pr.ts @@ -0,0 +1,608 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Migrates a pull request from microsoft/vscode-copilot-chat to microsoft/vscode. +// +// The diff is fetched, file paths are rewritten to prepend `extensions/copilot/`, +// and a new PR is created in microsoft/vscode via the `gh` CLI. +// +// Usage: +// node build/copilot-migrate-pr.ts [--dry-run] [--verbose] +// +// Requirements: +// - `gh` CLI installed and authenticated with access to both repos +// - Local checkout of microsoft/vscode with `main` branch up to date + +import { execFileSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as os from 'os'; +import { createInterface } from 'readline/promises'; +import { stdin as input, stdout as output } from 'process'; + +const SOURCE_REPO = 'microsoft/vscode-copilot-chat'; +const TARGET_REPO = 'microsoft/vscode'; +const PATH_PREFIX = 'extensions/copilot'; + +// --------------------------------------------------------------------------- +// CLI argument parsing +// --------------------------------------------------------------------------- + +interface Options { + prNumber: number; + dryRun: boolean; + verbose: boolean; +} + +function parseArgs(): Options { + const args = process.argv.slice(2); + let prNumber: number | undefined; + let dryRun = false; + let verbose = false; + + for (const arg of args) { + if (arg === '--dry-run') { + dryRun = true; + } else if (arg === '--verbose') { + verbose = true; + } else if (!prNumber && /^\d+$/.test(arg)) { + prNumber = parseInt(arg, 10); + } else { + console.error(`Unknown argument: ${arg}`); + process.exit(1); + } + } + + if (!prNumber) { + console.error('Usage: node build/copilot-migrate-pr.ts [--dry-run] [--verbose]'); + process.exit(1); + } + + return { prNumber, dryRun, verbose }; +} + +interface Logger { + info(message: string): void; + detail(message: string): void; + step(message: string): void; + warn(message: string): void; + success(message: string): void; +} + +function supportsColor(): boolean { + return Boolean(output.isTTY) && process.env.NO_COLOR === undefined && process.env.TERM !== 'dumb'; +} + +function color(text: string, code: number, enabled: boolean): string { + if (!enabled) { + return text; + } + + return `\u001b[${code}m${text}\u001b[0m`; +} + +function createLogger(verbose: boolean): Logger { + const useColor = supportsColor(); + const label = { + info: color('[INFO]', 36, useColor), + detail: color('[DETAIL]', 90, useColor), + step: color('[STEP]', 34, useColor), + warn: color('[WARN]', 33, useColor), + success: color('[DONE]', 32, useColor), + }; + + return { + info: message => console.log(`${label.info} ${message}`), + detail: message => { + if (verbose) { + console.log(`${label.detail} ${message}`); + } + }, + step: message => console.log(`\n${label.step} ${message}`), + warn: message => console.log(`${label.warn} ${message}`), + success: message => console.log(`\n${label.success} ${message}`), + }; +} + +async function promptYesNo(question: string, defaultNo = true): Promise { + const rl = createInterface({ input, output }); + try { + const suffix = defaultNo ? ' [y/N]: ' : ' [Y/n]: '; + const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase(); + + if (!answer) { + return !defaultNo; + } + + return answer === 'y' || answer === 'yes'; + } finally { + rl.close(); + } +} + +async function waitForEnter(message: string): Promise { + const rl = createInterface({ input, output }); + try { + await rl.question(`${message}\nPress Enter to continue...`); + } finally { + rl.close(); + } +} + +// --------------------------------------------------------------------------- +// gh CLI helpers +// --------------------------------------------------------------------------- + +function gh(args: string[]): string { + return execFileSync('gh', args, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 }); +} + +function git(args: string[], cwd?: string, env?: NodeJS.ProcessEnv): string { + return execFileSync('git', args, { + encoding: 'utf-8', + cwd, + env: env ? { ...process.env, ...env } : process.env, + maxBuffer: 50 * 1024 * 1024 + }); +} + +function getCurrentRef(repoRoot: string): string { + try { + return git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot).trim(); + } catch { + return git(['rev-parse', 'HEAD'], repoRoot).trim(); + } +} + +function checkoutRef(ref: string, repoRoot: string): void { + git(['checkout', ref], repoRoot); +} + +function getMigrationBranchName(prNumber: number): string { + return `vscode-copilot-chat/migrate-${prNumber}`; +} + +function remoteBranchExists(remote: string, branchName: string, repoRoot: string): boolean { + try { + git(['ls-remote', '--exit-code', '--heads', remote, branchName], repoRoot); + return true; + } catch (error) { + const status = (error as { status?: number }).status; + if (status === 2) { + return false; + } + + throw error; + } +} + +// --------------------------------------------------------------------------- +// PR metadata +// --------------------------------------------------------------------------- + +interface PrMetadata { + title: string; + body: string; + baseRefName: string; + headRefName: string; + state: string; + mergedAt: string | null; + isDraft: boolean; + number: number; + author: { login: string }; + labels: { name: string }[]; + assignees: { login: string }[]; +} + +function fetchPrMetadata(prNumber: number): PrMetadata { + const json = gh([ + 'pr', 'view', String(prNumber), + '--repo', SOURCE_REPO, + '--json', 'title,body,baseRefName,headRefName,state,mergedAt,isDraft,number,author,labels,assignees', + ]); + return JSON.parse(json); +} + +function fetchPrDiff(prNumber: number): string { + return gh([ + 'pr', 'diff', String(prNumber), + '--repo', SOURCE_REPO, + ]); +} + +interface CommitPerson { + name: string; + email: string; + date: string; +} + +interface SourceCommit { + sha: string; + author: CommitPerson | null; + committer: CommitPerson | null; +} + +function fetchPrCommits(prNumber: number): SourceCommit[] { + const json = gh([ + 'api', + `repos/${SOURCE_REPO}/pulls/${prNumber}/commits`, + '--paginate', + ]); + + const commits = JSON.parse(json) as Array<{ + sha: string; + commit: { + author: CommitPerson | null; + committer: CommitPerson | null; + }; + }>; + + return commits.map(commit => ({ + sha: commit.sha, + author: commit.commit.author, + committer: commit.commit.committer, + })); +} + +function fetchCommitPatch(sha: string): string { + return gh([ + 'api', + `repos/${SOURCE_REPO}/commits/${sha}`, + '-H', 'Accept: application/vnd.github.patch', + ]); +} + +interface DiffStats { + filesChanged: number; + insertions: number; + deletions: number; +} + +function getDiffStats(diff: string): DiffStats { + let filesChanged = 0; + let insertions = 0; + let deletions = 0; + + for (const line of diff.split('\n')) { + if (line.startsWith('diff --git ')) { + filesChanged++; + } else if (line.startsWith('+') && !line.startsWith('+++')) { + insertions++; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletions++; + } + } + + return { + filesChanged, + insertions, + deletions, + }; +} + +// --------------------------------------------------------------------------- +// Diff path rewriting +// --------------------------------------------------------------------------- + +/** + * Rewrites file paths in a unified diff to prepend `extensions/copilot/`. + * + * Handles: + * - `diff --git a/path b/path` + * - `--- a/path` / `+++ b/path` + * - `/dev/null` (new/deleted files) — left unchanged + * - `rename from path` / `rename to path` + * - `copy from path` / `copy to path` + */ +function rewriteDiff(diff: string): string { + const lines = diff.split('\n'); + const result: string[] = []; + + for (const line of lines) { + result.push(rewriteDiffLine(line)); + } + + return result.join('\n'); +} + +function rewriteDiffLine(line: string): string { + // diff --git a/path b/path + const diffGitMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/); + if (diffGitMatch) { + return `diff --git a/${PATH_PREFIX}/${diffGitMatch[1]} b/${PATH_PREFIX}/${diffGitMatch[2]}`; + } + + // --- a/path or --- /dev/null + const minusMatch = line.match(/^--- a\/(.+)$/); + if (minusMatch) { + return `--- a/${PATH_PREFIX}/${minusMatch[1]}`; + } + + // +++ b/path or +++ /dev/null + const plusMatch = line.match(/^\+\+\+ b\/(.+)$/); + if (plusMatch) { + return `+++ b/${PATH_PREFIX}/${plusMatch[1]}`; + } + + // rename from path / rename to path + const renameFromMatch = line.match(/^rename from (.+)$/); + if (renameFromMatch) { + return `rename from ${PATH_PREFIX}/${renameFromMatch[1]}`; + } + + const renameToMatch = line.match(/^rename to (.+)$/); + if (renameToMatch) { + return `rename to ${PATH_PREFIX}/${renameToMatch[1]}`; + } + + // copy from path / copy to path + const copyFromMatch = line.match(/^copy from (.+)$/); + if (copyFromMatch) { + return `copy from ${PATH_PREFIX}/${copyFromMatch[1]}`; + } + + const copyToMatch = line.match(/^copy to (.+)$/); + if (copyToMatch) { + return `copy to ${PATH_PREFIX}/${copyToMatch[1]}`; + } + + // Everything else (context lines, hunk headers, /dev/null, etc.) passes through + return line; +} + +// --------------------------------------------------------------------------- +// Branch and PR creation +// --------------------------------------------------------------------------- + +function hasActiveRebaseApply(repoRoot: string): boolean { + return fs.existsSync(path.join(repoRoot, '.git', 'rebase-apply')); +} + +async function resolveAmConflicts(repoRoot: string): Promise { + console.log('\nA commit patch could not be applied cleanly.'); + console.log('Resolve merge conflicts in your working tree, stage the changes, and continue.'); + for (; ;) { + await waitForEnter('After resolving conflicts and staging the changes, continue.'); + const unresolved = git(['diff', '--name-only', '--diff-filter=U'], repoRoot).trim(); + if (unresolved) { + console.log('\nThese files still have unresolved conflicts:'); + for (const file of unresolved.split('\n')) { + console.log(` - ${file}`); + } + continue; + } + + try { + git(['am', '--continue'], repoRoot); + break; + } catch (error) { + console.log(`\nCould not continue apply: ${error instanceof Error ? error.message : String(error)}`); + console.log('Fix any remaining issues, ensure all changes are staged, then try again.'); + } + } +} + +function amendHeadCommitMetadata(commit: SourceCommit, repoRoot: string): void { + if (!commit.author && !commit.committer) { + return; + } + + const author = commit.author ?? commit.committer; + const committer = commit.committer ?? commit.author; + if (!author || !committer) { + return; + } + + git([ + 'commit', + '--amend', + '--no-edit', + '--author', `${author.name} <${author.email}>`, + '--date', author.date, + ], repoRoot, { + GIT_COMMITTER_NAME: committer.name, + GIT_COMMITTER_EMAIL: committer.email, + GIT_COMMITTER_DATE: committer.date, + }); +} + +async function createBranchAndApplyCommits( + prNumber: number, + commits: SourceCommit[], + repoRoot: string, +): Promise { + const branchName = getMigrationBranchName(prNumber); + + // Ensure we're on a clean state based on main + git(['checkout', 'main'], repoRoot); + git(['pull', '--ff-only', 'origin', 'main'], repoRoot); + + // Create and switch to the new branch + try { + git(['checkout', '-b', branchName], repoRoot); + } catch { + // Branch may already exist from a previous attempt + git(['checkout', branchName], repoRoot); + git(['reset', '--hard', 'main'], repoRoot); + } + + for (let i = 0; i < commits.length; i++) { + const commit = commits[i]; + const patch = fetchCommitPatch(commit.sha); + const rewrittenPatch = rewriteDiff(patch); + const tmpPatch = path.join(os.tmpdir(), `copilot-migrate-pr-${prNumber}-${i + 1}.patch`); + + try { + fs.writeFileSync(tmpPatch, rewrittenPatch); + + try { + git(['am', '--3way', tmpPatch], repoRoot); + } catch { + if (!hasActiveRebaseApply(repoRoot)) { + throw new Error(`Failed to apply commit ${commit.sha}.`); + } + + await resolveAmConflicts(repoRoot); + } + } finally { + fs.unlinkSync(tmpPatch); + } + + amendHeadCommitMetadata(commit, repoRoot); + } + + if (!commits.length) { + git([ + 'commit', + '--allow-empty', + '-m', `Migrate ${SOURCE_REPO}#${prNumber}`, + ], repoRoot); + } + + return branchName; +} + +function closeSourcePr(prNumber: number, targetPrUrl: string): void { + const comment = `Superseded by ${targetPrUrl}`; + gh([ + 'pr', 'close', String(prNumber), + '--repo', SOURCE_REPO, + '--comment', comment, + ]); +} + +function pushBranch(branchName: string, repoRoot: string): void { + git(['push', '-u', 'origin', branchName, '--force-with-lease'], repoRoot); +} + +function createPr(meta: PrMetadata, branchName: string): string { + const migrationNote = [ + `> Migrated from ${SOURCE_REPO}#${meta.number}`, + `> Original author: @${meta.author.login}`, + '', + ].join('\n'); + + const body = meta.body + ? `${migrationNote}\n---\n\n${meta.body}` + : migrationNote; + + const args = [ + 'pr', 'create', + '--repo', TARGET_REPO, + '--head', branchName, + '--base', 'main', + '--title', meta.title, + '--body', body, + ]; + + if (meta.isDraft) { + args.push('--draft'); + } + + for (const assignee of meta.assignees) { + args.push('--assignee', assignee.login); + } + + for (const label of meta.labels) { + args.push('--label', label.name); + } + + return gh(args).trim(); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const { prNumber, dryRun, verbose } = parseArgs(); + const logger = createLogger(verbose); + const repoRoot = path.dirname(import.meta.dirname); + const originalRef = getCurrentRef(repoRoot); + const targetBranchName = getMigrationBranchName(prNumber); + let shouldRestoreRef = false; + + try { + logger.info(`Migrating PR #${prNumber} from ${SOURCE_REPO} to ${TARGET_REPO}`); + logger.detail(`Starting ref: ${originalRef}`); + + if (!dryRun) { + logger.step('Checking whether target branch already exists on origin'); + if (remoteBranchExists('origin', targetBranchName, repoRoot)) { + throw new Error(`Remote branch already exists: origin/${targetBranchName}. Delete it before rerunning.`); + } + logger.detail(`Target branch is available: origin/${targetBranchName}`); + } + + logger.step('Fetching source PR metadata'); + const meta = fetchPrMetadata(prNumber); + if (meta.state !== 'OPEN') { + const status = meta.mergedAt ? 'merged' : 'closed'; + throw new Error(`Source PR #${prNumber} is ${status}. Only open PRs can be migrated.`); + } + + logger.info(`Title: ${meta.title}`); + logger.detail(`Author: @${meta.author.login}`); + logger.detail(`Base: ${meta.baseRefName} -> Head: ${meta.headRefName}`); + logger.detail(`State: ${meta.state}`); + logger.detail(`Draft: ${meta.isDraft}`); + logger.detail(`Labels: ${meta.labels.map(l => l.name).join(', ') || '(none)'}`); + logger.detail(`Assignees: ${meta.assignees.map(a => a.login).join(', ') || '(none)'}`); + + logger.step('Fetching source PR commits'); + const commits = fetchPrCommits(prNumber); + logger.info(`Commit count: ${commits.length}`); + + logger.step('Fetching and rewriting diff'); + const diff = fetchPrDiff(prNumber); + const diffStats = getDiffStats(diff); + logger.detail(`Diff size: ${diff.length} bytes`); + logger.info(`Diff stats: ${diffStats.filesChanged} files changed, ${diffStats.insertions} insertions(+), ${diffStats.deletions} deletions(-)`); + + if (dryRun) { + logger.info('Dry run: no changes were made.'); + return; + } + + logger.step('Creating branch and applying commit series'); + const branchName = await createBranchAndApplyCommits(prNumber, commits, repoRoot); + shouldRestoreRef = true; + logger.info(`Branch: ${branchName}`); + + logger.step('Pushing branch'); + pushBranch(branchName, repoRoot); + + logger.step(`Creating PR in ${TARGET_REPO}`); + const prUrl = createPr(meta, branchName); + logger.success(`PR created: ${prUrl}`); + + const closeOldPr = await promptYesNo(`Close source PR #${prNumber} in ${SOURCE_REPO}?`); + if (closeOldPr) { + try { + closeSourcePr(prNumber, prUrl); + logger.info(`Closed source PR #${prNumber}`); + } catch (error) { + logger.warn(`Failed to close source PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + logger.info(`Left source PR #${prNumber} open`); + } + } finally { + if (shouldRestoreRef) { + logger.step(`Restoring original ref (${originalRef})`); + try { + checkoutRef(originalRef, repoRoot); + logger.info(`Checked out ${originalRef}`); + } catch (error) { + logger.warn(`Failed to restore original ref ${originalRef}: ${error instanceof Error ? error.message : String(error)}`); + } + } + } +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/build/lib/builtInExtensionsCG.ts b/build/lib/builtInExtensionsCG.ts index 1c4ce609c3da5..0737680fb72e1 100644 --- a/build/lib/builtInExtensionsCG.ts +++ b/build/lib/builtInExtensionsCG.ts @@ -21,10 +21,15 @@ const contentFileNames = ['package.json', 'package-lock.json']; async function downloadExtensionDetails(extension: IExtensionDefinition): Promise { const extensionLabel = `${extension.name}@${extension.version}`; + + if (!extension.repo) { + console.log(`Skipping CG for ${extensionLabel} because no repository is defined`); + return; + } + const repository = url.parse(extension.repo).path!.substr(1); const repositoryContentBaseUrl = `https://${token ? `${token}@` : ''}${contentBasePath}/${repository}/v${extension.version}`; - async function getContent(fileName: string): Promise<{ fileName: string; body: Buffer | undefined | null }> { try { const response = await fetch(`${repositoryContentBaseUrl}/${fileName}`); diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index a5291281ba06e..bfa0644b2ec81 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -9,42 +9,22 @@ use std::io::{Read, Write}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::{Duration, Instant}; use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Request, Response, Server}; -use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::sync::Mutex; +use hyper::Server; -use crate::async_pipe::{get_socket_name, get_socket_rw_stream, AsyncPipe}; -use crate::constants::VSCODE_CLI_QUALITY; -use crate::download_cache::DownloadCache; use crate::log; -use crate::options::Quality; -use crate::tunnels::paths::{get_server_folder_name, SERVER_FOLDER_NAME}; +use crate::tunnels::agent_host::{handle_request, AgentHostConfig, AgentHostManager}; +use crate::tunnels::legal; use crate::tunnels::shutdown_signal::ShutdownRequest; -use crate::update_service::{ - unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, -}; -use crate::util::command::new_script_command; +use crate::update_service::Platform; use crate::util::errors::AnyError; -use crate::util::http::{self, ReqwestSimpleHttp}; -use crate::util::io::SilentCopyProgress; -use crate::util::sync::{new_barrier, Barrier, BarrierOpener}; -use crate::{ - tunnels::legal, - util::{errors::CodeError, prereqs::PreReqChecker}, -}; +use crate::util::errors::CodeError; +use crate::util::http::ReqwestSimpleHttp; +use crate::util::prereqs::PreReqChecker; use super::{args::AgentHostArgs, CommandContext}; -/// How often to check for server updates. -const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); -/// How often to re-check whether the server has exited when an update is pending. -const UPDATE_POLL_INTERVAL: Duration = Duration::from_secs(10 * 60); -/// How long to wait for the server to signal readiness. -const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); - /// Runs a local agent host server. Downloads the latest VS Code server on /// demand, starts it with `--enable-remote-auto-shutdown`, and proxies /// WebSocket connections from a local TCP port to the server's agent host @@ -69,7 +49,18 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< } } - let manager = AgentHostManager::new(&ctx, platform, args.clone())?; + let manager = AgentHostManager::new( + ctx.log.clone(), + platform, + ctx.paths.server_cache.clone(), + Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())), + AgentHostConfig { + server_data_dir: args.server_data_dir.clone(), + without_connection_token: args.without_connection_token, + connection_token: args.connection_token.clone(), + connection_token_file: args.connection_token_file.clone(), + }, + ); // Eagerly resolve the latest version so the first connection is fast. // Skip when using a dev override since updates don't apply. @@ -130,541 +121,6 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< Ok(0) } -// ---- AgentHostManager ------------------------------------------------------- - -/// State of the running VS Code server process. -struct RunningServer { - child: tokio::process::Child, - commit: String, -} - -/// Manages the VS Code server lifecycle: on-demand start, auto-restart -/// after idle shutdown, and background update checking. -struct AgentHostManager { - log: log::Logger, - args: AgentHostArgs, - platform: Platform, - cache: DownloadCache, - update_service: UpdateService, - /// The latest known release, with the time it was checked. - latest_release: Mutex>, - /// The currently running server, if any. - running: Mutex>, - /// Barrier that opens when a server is ready (socket path available). - /// Reset each time a new server is started. - ready: Mutex>>>, -} - -impl AgentHostManager { - fn new( - ctx: &CommandContext, - platform: Platform, - args: AgentHostArgs, - ) -> Result, CodeError> { - // Seed latest_release from cache if available - let cache = ctx.paths.server_cache.clone(); - Ok(Arc::new(Self { - log: ctx.log.clone(), - args, - platform, - cache, - update_service: UpdateService::new( - ctx.log.clone(), - Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())), - ), - latest_release: Mutex::new(None), - running: Mutex::new(None), - ready: Mutex::new(None), - })) - } - - /// Returns the socket path to a running server, starting one if needed. - async fn ensure_server(self: &Arc) -> Result { - // Fast path: if we already have a barrier, wait on it - { - let ready = self.ready.lock().await; - if let Some(barrier) = &*ready { - if barrier.is_open() { - // Check if the process is still running - let running = self.running.lock().await; - if running.is_some() { - return barrier - .clone() - .wait() - .await - .unwrap() - .map_err(CodeError::ServerDownloadError); - } - } else { - // Still starting up, wait for it - let mut barrier = barrier.clone(); - drop(ready); - return barrier - .wait() - .await - .unwrap() - .map_err(CodeError::ServerDownloadError); - } - } - } - - // Need to start a new server - self.start_server().await - } - - /// Starts the server with the latest already-downloaded version. - /// Only blocks on a network fetch if no version has been downloaded yet. - async fn start_server(self: &Arc) -> Result { - let (release, server_dir) = self.get_cached_or_download().await?; - - let (mut barrier, opener) = new_barrier::>(); - { - let mut ready = self.ready.lock().await; - *ready = Some(barrier.clone()); - } - - let self_clone = self.clone(); - let release_clone = release.clone(); - tokio::spawn(async move { - self_clone - .run_server(release_clone, server_dir, opener) - .await; - }); - - barrier - .wait() - .await - .unwrap() - .map_err(CodeError::ServerDownloadError) - } - - /// Runs the server process to completion, handling readiness signaling. - async fn run_server( - self: &Arc, - release: Release, - server_dir: PathBuf, - opener: BarrierOpener>, - ) { - let executable = if let Some(p) = option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH") { - PathBuf::from(p) - } else { - server_dir - .join(SERVER_FOLDER_NAME) - .join("bin") - .join(release.quality.server_entrypoint()) - }; - - let agent_host_socket = get_socket_name(); - let mut cmd = new_script_command(&executable); - cmd.stdin(std::process::Stdio::null()); - cmd.stderr(std::process::Stdio::piped()); - cmd.stdout(std::process::Stdio::piped()); - cmd.arg("--socket-path"); - cmd.arg(get_socket_name()); - cmd.arg("--agent-host-path"); - cmd.arg(&agent_host_socket); - cmd.args([ - "--start-server", - "--accept-server-license-terms", - "--enable-remote-auto-shutdown", - ]); - - if let Some(a) = &self.args.server_data_dir { - cmd.arg("--server-data-dir"); - cmd.arg(a); - } - if self.args.without_connection_token { - cmd.arg("--without-connection-token"); - } - if let Some(ct) = &self.args.connection_token_file { - cmd.arg("--connection-token-file"); - cmd.arg(ct); - } - cmd.env_remove("VSCODE_DEV"); - - let mut child = match cmd.spawn() { - Ok(c) => c, - Err(e) => { - opener.open(Err(e.to_string())); - return; - } - }; - - let commit_prefix = &release.commit[..release.commit.len().min(7)]; - let (mut stdout, mut stderr) = ( - BufReader::new(child.stdout.take().unwrap()).lines(), - BufReader::new(child.stderr.take().unwrap()).lines(), - ); - - // Wait for readiness with a timeout - let mut opener = Some(opener); - let socket_path = agent_host_socket.clone(); - let startup_deadline = tokio::time::sleep(STARTUP_TIMEOUT); - tokio::pin!(startup_deadline); - - let mut ready = false; - loop { - tokio::select! { - Ok(Some(l)) = stdout.next_line() => { - debug!(self.log, "[{} stdout]: {}", commit_prefix, l); - if !ready && l.contains("Agent host server listening on") { - ready = true; - if let Some(o) = opener.take() { - o.open(Ok(socket_path.clone())); - } - } - } - Ok(Some(l)) = stderr.next_line() => { - debug!(self.log, "[{} stderr]: {}", commit_prefix, l); - } - _ = &mut startup_deadline, if !ready => { - warning!(self.log, "[{}]: Server did not become ready within {}s", commit_prefix, STARTUP_TIMEOUT.as_secs()); - // Don't fail — the server may still start up, just slowly - if let Some(o) = opener.take() { - o.open(Ok(socket_path.clone())); - } - ready = true; - } - e = child.wait() => { - info!(self.log, "[{} process]: exited: {:?}", commit_prefix, e); - if let Some(o) = opener.take() { - o.open(Err(format!("Server exited before ready: {e:?}"))); - } - break; - } - } - - if ready { - break; - } - } - - // Store the running server state - { - let mut running = self.running.lock().await; - *running = Some(RunningServer { - child, - commit: release.commit.clone(), - }); - } - - if !ready { - return; - } - - info!(self.log, "[{}]: Server ready", commit_prefix); - - // Continue reading output until the process exits - let log = self.log.clone(); - let commit_prefix = commit_prefix.to_string(); - let self_clone = self.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - Ok(Some(l)) = stdout.next_line() => { - debug!(log, "[{} stdout]: {}", commit_prefix, l); - } - Ok(Some(l)) = stderr.next_line() => { - debug!(log, "[{} stderr]: {}", commit_prefix, l); - } - else => break, - } - } - - // Server process has exited (auto-shutdown or crash) - info!(log, "[{}]: Server process ended", commit_prefix); - let mut running = self_clone.running.lock().await; - if let Some(r) = &*running { - if r.commit == commit_prefix || r.commit.starts_with(&commit_prefix) { - // Only clear if it's still our server - } - } - *running = None; - }); - } - - /// Returns a release and its local directory. Prefers the latest known - /// release if it has already been downloaded; otherwise falls back to any - /// cached version. Only fetches from the network and downloads if - /// nothing is cached at all. - async fn get_cached_or_download(&self) -> Result<(Release, PathBuf), CodeError> { - // When using a dev override, skip the update service entirely - - // the override path is used directly by run_server(). - if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_some() { - let release = Release { - name: String::new(), - commit: String::from("dev"), - platform: self.platform, - target: TargetKind::Server, - quality: Quality::Insiders, - }; - return Ok((release, PathBuf::new())); - } - - // Best case: the latest known release is already downloaded - if let Some((_, release)) = &*self.latest_release.lock().await { - let name = get_server_folder_name(release.quality, &release.commit); - if let Some(dir) = self.cache.exists(&name) { - return Ok((release.clone(), dir)); - } - } - - let quality = VSCODE_CLI_QUALITY - .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) - .and_then(|q| { - Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) - })?; - - // Fall back to any cached version (still instant, just not the newest). - // Cache entries are named "-" via get_server_folder_name. - for entry in self.cache.get() { - if let Some(dir) = self.cache.exists(&entry) { - let (entry_quality, commit) = match entry.split_once('-') { - Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) { - Ok(parsed) => (parsed, c.to_string()), - Err(_) => (quality, entry.clone()), - }, - None => (quality, entry.clone()), - }; - let release = Release { - name: String::new(), - commit, - platform: self.platform, - target: TargetKind::Server, - quality: entry_quality, - }; - return Ok((release, dir)); - } - } - - // Nothing cached — must fetch and download (blocks the first connection) - info!(self.log, "No cached server version, downloading latest..."); - let release = self.get_latest_release().await?; - let dir = self.ensure_downloaded(&release).await?; - Ok((release, dir)) - } - - /// Ensures the release is downloaded, returning the server directory. - async fn ensure_downloaded(&self, release: &Release) -> Result { - let cache_name = get_server_folder_name(release.quality, &release.commit); - if let Some(dir) = self.cache.exists(&cache_name) { - return Ok(dir); - } - - info!(self.log, "Downloading server {}", release.commit); - let release = release.clone(); - let log = self.log.clone(); - let update_service = self.update_service.clone(); - self.cache - .create(&cache_name, |target_dir| async move { - let tmpdir = tempfile::tempdir().unwrap(); - let response = update_service.get_download_stream(&release).await?; - let name = response.url_path_basename().unwrap(); - let archive_path = tmpdir.path().join(name); - http::download_into_file( - &archive_path, - log.get_download_logger("Downloading server:"), - response, - ) - .await?; - let server_dir = target_dir.join(SERVER_FOLDER_NAME); - unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?; - Ok(()) - }) - .await - .map_err(|e| CodeError::ServerDownloadError(e.to_string())) - } - - /// Gets the latest release, caching the result. - async fn get_latest_release(&self) -> Result { - let mut latest = self.latest_release.lock().await; - let now = Instant::now(); - - let quality = VSCODE_CLI_QUALITY - .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) - .and_then(|q| { - Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) - })?; - - let result = self - .update_service - .get_latest_commit(self.platform, TargetKind::Server, quality) - .await - .map_err(|e| CodeError::UpdateCheckFailed(e.to_string())); - - // If the update service is unavailable, fall back to the cached version - if let (Err(e), Some((_, previous))) = (&result, latest.clone()) { - warning!(self.log, "Error checking for updates, using cached: {}", e); - *latest = Some((now, previous.clone())); - return Ok(previous); - } - - let release = result?; - debug!(self.log, "Resolved server version: {}", release); - *latest = Some((now, release.clone())); - Ok(release) - } - - /// Background loop: checks for updates periodically and pre-downloads - /// new versions when the server is idle. - async fn run_update_loop(self: Arc) { - let mut interval = tokio::time::interval(UPDATE_CHECK_INTERVAL); - interval.tick().await; // skip the immediate first tick - - loop { - interval.tick().await; - - let new_release = match self.get_latest_release().await { - Ok(r) => r, - Err(e) => { - warning!(self.log, "Update check failed: {}", e); - continue; - } - }; - - // Check if we already have this version - let name = get_server_folder_name(new_release.quality, &new_release.commit); - if self.cache.exists(&name).is_some() { - continue; - } - - info!(self.log, "New server version available: {}", new_release); - - // Wait until the server is not running before downloading - loop { - { - let running = self.running.lock().await; - if running.is_none() { - break; - } - } - debug!(self.log, "Server still running, waiting before updating..."); - tokio::time::sleep(UPDATE_POLL_INTERVAL).await; - } - - // Download the new version - match self.ensure_downloaded(&new_release).await { - Ok(_) => info!(self.log, "Updated server to {}", new_release), - Err(e) => warning!(self.log, "Failed to download update: {}", e), - } - } - } - - /// Kills the currently running server, if any. - async fn kill_running_server(&self) { - let mut running = self.running.lock().await; - if let Some(mut server) = running.take() { - let _ = server.child.kill().await; - } - } -} - -// ---- HTTP/WebSocket proxy --------------------------------------------------- - -/// Proxies an incoming HTTP/WebSocket request to the agent host's Unix socket. -async fn handle_request( - manager: Arc, - req: Request, -) -> Result, Infallible> { - let socket_path = match manager.ensure_server().await { - Ok(p) => p, - Err(e) => { - error!(manager.log, "Error starting agent host: {:?}", e); - return Ok(Response::builder() - .status(503) - .body(Body::from(format!("Error starting agent host: {e:?}"))) - .unwrap()); - } - }; - - let is_upgrade = req.headers().contains_key(hyper::header::UPGRADE); - - let rw = match get_socket_rw_stream(&socket_path).await { - Ok(rw) => rw, - Err(e) => { - error!( - manager.log, - "Error connecting to agent host socket: {:?}", e - ); - return Ok(Response::builder() - .status(503) - .body(Body::from(format!("Error connecting to agent host: {e:?}"))) - .unwrap()); - } - }; - - if is_upgrade { - Ok(forward_ws_to_server(rw, req).await) - } else { - Ok(forward_http_to_server(rw, req).await) - } -} - -/// Proxies a standard HTTP request through the socket. -async fn forward_http_to_server(rw: AsyncPipe, req: Request) -> Response { - let (mut request_sender, connection) = - match hyper::client::conn::Builder::new().handshake(rw).await { - Ok(r) => r, - Err(e) => return connection_err(e), - }; - - tokio::spawn(connection); - - request_sender - .send_request(req) - .await - .unwrap_or_else(connection_err) -} - -/// Proxies a WebSocket upgrade request through the socket. -async fn forward_ws_to_server(rw: AsyncPipe, mut req: Request) -> Response { - let (mut request_sender, connection) = - match hyper::client::conn::Builder::new().handshake(rw).await { - Ok(r) => r, - Err(e) => return connection_err(e), - }; - - tokio::spawn(connection); - - let mut proxied_req = Request::builder().uri(req.uri()); - for (k, v) in req.headers() { - proxied_req = proxied_req.header(k, v); - } - - let mut res = request_sender - .send_request(proxied_req.body(Body::empty()).unwrap()) - .await - .unwrap_or_else(connection_err); - - let mut proxied_res = Response::new(Body::empty()); - *proxied_res.status_mut() = res.status(); - for (k, v) in res.headers() { - proxied_res.headers_mut().insert(k, v.clone()); - } - - if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS { - tokio::spawn(async move { - let (s_req, s_res) = - tokio::join!(hyper::upgrade::on(&mut req), hyper::upgrade::on(&mut res)); - - if let (Ok(mut s_req), Ok(mut s_res)) = (s_req, s_res) { - let _ = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await; - } - }); - } - - proxied_res -} - -fn connection_err(err: hyper::Error) -> Response { - Response::builder() - .status(503) - .body(Body::from(format!( - "Error connecting to agent host: {err:?}" - ))) - .unwrap() -} - fn mint_connection_token(path: &Path, prefer_token: Option) -> std::io::Result { #[cfg(not(windows))] use std::os::unix::fs::OpenOptionsExt; diff --git a/cli/src/commands/tunnels.rs b/cli/src/commands/tunnels.rs index 06d8dc842262f..8f99f8d37d660 100644 --- a/cli/src/commands/tunnels.rs +++ b/cli/src/commands/tunnels.rs @@ -31,7 +31,8 @@ use crate::{ async_pipe::{get_socket_name, listen_socket_rw_stream, AsyncRWAccepter}, auth::Auth, constants::{ - APPLICATION_NAME, CONTROL_PORT, IS_A_TTY, TUNNEL_CLI_LOCK_NAME, TUNNEL_SERVICE_LOCK_NAME, + AGENT_HOST_PORT, APPLICATION_NAME, CONTROL_PORT, IS_A_TTY, TUNNEL_CLI_LOCK_NAME, + TUNNEL_SERVICE_LOCK_NAME, }, log, state::LauncherPaths, @@ -330,7 +331,8 @@ pub async fn user(ctx: CommandContext, user_args: TunnelUserSubCommands) -> Resu } TunnelUserSubCommands::Show => { if let Ok(Some(sc)) = auth.get_current_credential() { - ctx.log.result(format!("logged in with provider {}", sc.provider)); + ctx.log + .result(format!("logged in with provider {}", sc.provider)); } else { ctx.log.result("not logged in"); return Ok(1); @@ -649,7 +651,7 @@ async fn serve_with_csa( dt.start_existing_tunnel(t).await } else { tokio::select! { - t = dt.start_new_launcher_tunnel(gateway_args.name.as_deref(), gateway_args.random_name, &[CONTROL_PORT]) => t, + t = dt.start_new_launcher_tunnel(gateway_args.name.as_deref(), gateway_args.random_name, &[CONTROL_PORT, AGENT_HOST_PORT]) => t, _ = shutdown.wait() => return Ok(1), } }?; diff --git a/cli/src/constants.rs b/cli/src/constants.rs index 1e277a89d6af2..9e2b066d74139 100644 --- a/cli/src/constants.rs +++ b/cli/src/constants.rs @@ -12,6 +12,7 @@ use lazy_static::lazy_static; use crate::options::Quality; pub const CONTROL_PORT: u16 = 31545; +pub const AGENT_HOST_PORT: u16 = 31546; /// Protocol version sent to clients. This can be used to indicate new or /// changed capabilities that clients may wish to leverage. @@ -20,7 +21,8 @@ pub const CONTROL_PORT: u16 = 31545; /// are compressed bidirectionally. /// 3 - The server's connection token is set to a SHA256 hash of the tunnel ID /// 4 - The server's msgpack messages are no longer length-prefixed -pub const PROTOCOL_VERSION: u32 = 4; +/// 5 - The server now exposes an agent host connection +pub const PROTOCOL_VERSION: u32 = 5; /// Prefix for the tunnel tag that includes the version. pub const PROTOCOL_VERSION_TAG_PREFIX: &str = "protocolv"; diff --git a/cli/src/tunnels.rs b/cli/src/tunnels.rs index 452da4dc3e96d..d99b151a3138f 100644 --- a/cli/src/tunnels.rs +++ b/cli/src/tunnels.rs @@ -13,6 +13,7 @@ pub mod shutdown_signal; pub mod singleton_client; pub mod singleton_server; +pub mod agent_host; mod challenge; mod control_server; mod nosleep; diff --git a/cli/src/tunnels/agent_host.rs b/cli/src/tunnels/agent_host.rs new file mode 100644 index 0000000000000..9d1f240c5099e --- /dev/null +++ b/cli/src/tunnels/agent_host.rs @@ -0,0 +1,574 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::convert::Infallible; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use hyper::{Body, Request, Response}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::Mutex; + +use crate::async_pipe::{get_socket_name, get_socket_rw_stream, AsyncPipe}; +use crate::constants::VSCODE_CLI_QUALITY; +use crate::download_cache::DownloadCache; +use crate::log; +use crate::options::Quality; +use crate::update_service::{ + unzip_downloaded_release, Platform, Release, TargetKind, UpdateService, +}; +use crate::util::command::new_script_command; +use crate::util::errors::CodeError; +use crate::util::http::{self, BoxedHttp}; +use crate::util::io::SilentCopyProgress; +use crate::util::sync::{new_barrier, Barrier, BarrierOpener}; + +use super::paths::{get_server_folder_name, SERVER_FOLDER_NAME}; + +/// How often to check for server updates. +pub const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +/// How often to re-check whether the server has exited when an update is pending. +pub const UPDATE_POLL_INTERVAL: Duration = Duration::from_secs(10 * 60); +/// How long to wait for the server to signal readiness. +pub const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Configuration for the agent host server process. +#[derive(Clone, Debug)] +pub struct AgentHostConfig { + pub server_data_dir: Option, + pub without_connection_token: bool, + pub connection_token: Option, + pub connection_token_file: Option, +} + +/// State of the running VS Code server process. +struct RunningServer { + child: tokio::process::Child, + commit: String, +} + +/// Manages the VS Code server lifecycle: on-demand start, auto-restart +/// after idle shutdown, and background update checking. +pub struct AgentHostManager { + log: log::Logger, + config: AgentHostConfig, + platform: Platform, + cache: DownloadCache, + update_service: UpdateService, + /// The latest known release, with the time it was checked. + latest_release: Mutex>, + /// The currently running server, if any. + running: Mutex>, + /// Barrier that opens when a server is ready (socket path available). + /// Reset each time a new server is started. + ready: Mutex>>>, +} + +impl AgentHostManager { + pub fn new( + log: log::Logger, + platform: Platform, + cache: DownloadCache, + http: BoxedHttp, + config: AgentHostConfig, + ) -> Arc { + Arc::new(Self { + update_service: UpdateService::new(log.clone(), http), + log, + config, + platform, + cache, + latest_release: Mutex::new(None), + running: Mutex::new(None), + ready: Mutex::new(None), + }) + } + + /// Returns the socket path to a running server, starting one if needed. + pub async fn ensure_server(self: &Arc) -> Result { + // Fast path: if we already have a barrier, wait on it + { + let ready = self.ready.lock().await; + if let Some(barrier) = &*ready { + if barrier.is_open() { + // Check if the process is still running + let running = self.running.lock().await; + if running.is_some() { + return barrier + .clone() + .wait() + .await + .unwrap() + .map_err(CodeError::ServerDownloadError); + } + } else { + // Still starting up, wait for it + let mut barrier = barrier.clone(); + drop(ready); + return barrier + .wait() + .await + .unwrap() + .map_err(CodeError::ServerDownloadError); + } + } + } + + // Need to start a new server + self.start_server().await + } + + /// Starts the server with the latest already-downloaded version. + /// Only blocks on a network fetch if no version has been downloaded yet. + async fn start_server(self: &Arc) -> Result { + let (release, server_dir) = self.get_cached_or_download().await?; + + let (mut barrier, opener) = new_barrier::>(); + { + let mut ready = self.ready.lock().await; + *ready = Some(barrier.clone()); + } + + let self_clone = self.clone(); + let release_clone = release.clone(); + tokio::spawn(async move { + self_clone + .run_server(release_clone, server_dir, opener) + .await; + }); + + barrier + .wait() + .await + .unwrap() + .map_err(CodeError::ServerDownloadError) + } + + /// Runs the server process to completion, handling readiness signaling. + async fn run_server( + self: &Arc, + release: Release, + server_dir: PathBuf, + opener: BarrierOpener>, + ) { + let executable = if let Some(p) = option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH") { + PathBuf::from(p) + } else { + server_dir + .join(SERVER_FOLDER_NAME) + .join("bin") + .join(release.quality.server_entrypoint()) + }; + + let agent_host_socket = get_socket_name(); + let mut cmd = new_script_command(&executable); + cmd.stdin(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.arg("--socket-path"); + cmd.arg(get_socket_name()); + cmd.arg("--agent-host-path"); + cmd.arg(&agent_host_socket); + cmd.args([ + "--start-server", + "--accept-server-license-terms", + "--enable-remote-auto-shutdown", + ]); + + if let Some(a) = &self.config.server_data_dir { + cmd.arg("--server-data-dir"); + cmd.arg(a); + } + if self.config.without_connection_token { + cmd.arg("--without-connection-token"); + } + if let Some(ct) = &self.config.connection_token_file { + cmd.arg("--connection-token-file"); + cmd.arg(ct); + } + cmd.env_remove("VSCODE_DEV"); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + opener.open(Err(e.to_string())); + return; + } + }; + + let commit_prefix = &release.commit[..release.commit.len().min(7)]; + let (mut stdout, mut stderr) = ( + BufReader::new(child.stdout.take().unwrap()).lines(), + BufReader::new(child.stderr.take().unwrap()).lines(), + ); + + // Wait for readiness with a timeout + let mut opener = Some(opener); + let socket_path = agent_host_socket.clone(); + let startup_deadline = tokio::time::sleep(STARTUP_TIMEOUT); + tokio::pin!(startup_deadline); + + let mut ready = false; + loop { + tokio::select! { + Ok(Some(l)) = stdout.next_line() => { + debug!(self.log, "[{} stdout]: {}", commit_prefix, l); + if !ready && l.contains("Agent host server listening on") { + ready = true; + if let Some(o) = opener.take() { + o.open(Ok(socket_path.clone())); + } + } + } + Ok(Some(l)) = stderr.next_line() => { + debug!(self.log, "[{} stderr]: {}", commit_prefix, l); + } + _ = &mut startup_deadline, if !ready => { + warning!(self.log, "[{}]: Server did not become ready within {}s", commit_prefix, STARTUP_TIMEOUT.as_secs()); + // Don't fail — the server may still start up, just slowly + if let Some(o) = opener.take() { + o.open(Ok(socket_path.clone())); + } + ready = true; + } + e = child.wait() => { + info!(self.log, "[{} process]: exited: {:?}", commit_prefix, e); + if let Some(o) = opener.take() { + o.open(Err(format!("Server exited before ready: {e:?}"))); + } + break; + } + } + + if ready { + break; + } + } + + // Store the running server state + { + let mut running = self.running.lock().await; + *running = Some(RunningServer { + child, + commit: release.commit.clone(), + }); + } + + if !ready { + return; + } + + info!(self.log, "[{}]: Server ready", commit_prefix); + + // Continue reading output until the process exits + let log = self.log.clone(); + let commit_prefix = commit_prefix.to_string(); + let self_clone = self.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + Ok(Some(l)) = stdout.next_line() => { + debug!(log, "[{} stdout]: {}", commit_prefix, l); + } + Ok(Some(l)) = stderr.next_line() => { + debug!(log, "[{} stderr]: {}", commit_prefix, l); + } + else => break, + } + } + + // Server process has exited (auto-shutdown or crash) + info!(log, "[{}]: Server process ended", commit_prefix); + let mut running = self_clone.running.lock().await; + if let Some(r) = &*running { + if r.commit == commit_prefix || r.commit.starts_with(&commit_prefix) { + *running = None; + } + } + }); + } + + /// Returns a release and its local directory. Prefers the latest known + /// release if it has already been downloaded; otherwise falls back to any + /// cached version. Only fetches from the network and downloads if + /// nothing is cached at all. + async fn get_cached_or_download(&self) -> Result<(Release, PathBuf), CodeError> { + // When using a dev override, skip the update service entirely - + // the override path is used directly by run_server(). + if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_some() { + let release = Release { + name: String::new(), + commit: String::from("dev"), + platform: self.platform, + target: TargetKind::Server, + quality: Quality::Insiders, + }; + return Ok((release, PathBuf::new())); + } + + // Best case: the latest known release is already downloaded + if let Some((_, release)) = &*self.latest_release.lock().await { + let name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&name) { + return Ok((release.clone(), dir)); + } + } + + let quality = VSCODE_CLI_QUALITY + .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .and_then(|q| { + Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) + })?; + + // Fall back to any cached version (still instant, just not the newest). + // Cache entries are named "-" via get_server_folder_name. + for entry in self.cache.get() { + if let Some(dir) = self.cache.exists(&entry) { + let (entry_quality, commit) = match entry.split_once('-') { + Some((q, c)) => match Quality::try_from(q.to_lowercase().as_str()) { + Ok(parsed) => (parsed, c.to_string()), + Err(_) => (quality, entry.clone()), + }, + None => (quality, entry.clone()), + }; + let release = Release { + name: String::new(), + commit, + platform: self.platform, + target: TargetKind::Server, + quality: entry_quality, + }; + return Ok((release, dir)); + } + } + + // Nothing cached — must fetch and download (blocks the first connection) + info!(self.log, "No cached server version, downloading latest..."); + let release = self.get_latest_release().await?; + let dir = self.ensure_downloaded(&release).await?; + Ok((release, dir)) + } + + /// Ensures the release is downloaded, returning the server directory. + pub async fn ensure_downloaded(&self, release: &Release) -> Result { + let cache_name = get_server_folder_name(release.quality, &release.commit); + if let Some(dir) = self.cache.exists(&cache_name) { + return Ok(dir); + } + + info!(self.log, "Downloading server {}", release.commit); + let release = release.clone(); + let log = self.log.clone(); + let update_service = self.update_service.clone(); + self.cache + .create(&cache_name, |target_dir| async move { + let tmpdir = tempfile::tempdir().unwrap(); + let response = update_service.get_download_stream(&release).await?; + let name = response.url_path_basename().unwrap(); + let archive_path = tmpdir.path().join(name); + http::download_into_file( + &archive_path, + log.get_download_logger("Downloading server:"), + response, + ) + .await?; + let server_dir = target_dir.join(SERVER_FOLDER_NAME); + unzip_downloaded_release(&archive_path, &server_dir, SilentCopyProgress())?; + Ok(()) + }) + .await + .map_err(|e| CodeError::ServerDownloadError(e.to_string())) + } + + /// Gets the latest release, caching the result. + pub async fn get_latest_release(&self) -> Result { + let mut latest = self.latest_release.lock().await; + let now = Instant::now(); + + let quality = VSCODE_CLI_QUALITY + .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .and_then(|q| { + Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) + })?; + + let result = self + .update_service + .get_latest_commit(self.platform, TargetKind::Server, quality) + .await + .map_err(|e| CodeError::UpdateCheckFailed(e.to_string())); + + // If the update service is unavailable, fall back to the cached version + if let (Err(e), Some((_, previous))) = (&result, latest.clone()) { + warning!(self.log, "Error checking for updates, using cached: {}", e); + *latest = Some((now, previous.clone())); + return Ok(previous); + } + + let release = result?; + debug!(self.log, "Resolved server version: {}", release); + *latest = Some((now, release.clone())); + Ok(release) + } + + /// Background loop: checks for updates periodically and pre-downloads + /// new versions when the server is idle. + pub async fn run_update_loop(self: Arc) { + let mut interval = tokio::time::interval(UPDATE_CHECK_INTERVAL); + interval.tick().await; // skip the immediate first tick + + loop { + interval.tick().await; + + let new_release = match self.get_latest_release().await { + Ok(r) => r, + Err(e) => { + warning!(self.log, "Update check failed: {}", e); + continue; + } + }; + + // Check if we already have this version + let name = get_server_folder_name(new_release.quality, &new_release.commit); + if self.cache.exists(&name).is_some() { + continue; + } + + info!(self.log, "New server version available: {}", new_release); + + // Wait until the server is not running before downloading + loop { + { + let running = self.running.lock().await; + if running.is_none() { + break; + } + } + debug!(self.log, "Server still running, waiting before updating..."); + tokio::time::sleep(UPDATE_POLL_INTERVAL).await; + } + + // Download the new version + match self.ensure_downloaded(&new_release).await { + Ok(_) => info!(self.log, "Updated server to {}", new_release), + Err(e) => warning!(self.log, "Failed to download update: {}", e), + } + } + } + + /// Kills the currently running server, if any. + pub async fn kill_running_server(&self) { + let mut running = self.running.lock().await; + if let Some(mut server) = running.take() { + let _ = server.child.kill().await; + } + } +} + +// ---- HTTP/WebSocket proxy --------------------------------------------------- + +/// Proxies an incoming HTTP/WebSocket request to the agent host's Unix socket. +pub async fn handle_request( + manager: Arc, + req: Request, +) -> Result, Infallible> { + let socket_path = match manager.ensure_server().await { + Ok(p) => p, + Err(e) => { + error!(manager.log, "Error starting agent host: {:?}", e); + return Ok(Response::builder() + .status(503) + .body(Body::from(format!("Error starting agent host: {e:?}"))) + .unwrap()); + } + }; + + let is_upgrade = req.headers().contains_key(hyper::header::UPGRADE); + + let rw = match get_socket_rw_stream(&socket_path).await { + Ok(rw) => rw, + Err(e) => { + error!( + manager.log, + "Error connecting to agent host socket: {:?}", e + ); + return Ok(Response::builder() + .status(503) + .body(Body::from(format!("Error connecting to agent host: {e:?}"))) + .unwrap()); + } + }; + + if is_upgrade { + Ok(forward_ws_to_server(rw, req).await) + } else { + Ok(forward_http_to_server(rw, req).await) + } +} + +/// Proxies a standard HTTP request through the socket. +async fn forward_http_to_server(rw: AsyncPipe, req: Request) -> Response { + let (mut request_sender, connection) = + match hyper::client::conn::Builder::new().handshake(rw).await { + Ok(r) => r, + Err(e) => return connection_err(e), + }; + + tokio::spawn(connection); + + request_sender + .send_request(req) + .await + .unwrap_or_else(connection_err) +} + +/// Proxies a WebSocket upgrade request through the socket. +async fn forward_ws_to_server(rw: AsyncPipe, mut req: Request) -> Response { + let (mut request_sender, connection) = + match hyper::client::conn::Builder::new().handshake(rw).await { + Ok(r) => r, + Err(e) => return connection_err(e), + }; + + tokio::spawn(connection); + + let mut proxied_req = Request::builder().uri(req.uri()); + for (k, v) in req.headers() { + proxied_req = proxied_req.header(k, v); + } + + let mut res = request_sender + .send_request(proxied_req.body(Body::empty()).unwrap()) + .await + .unwrap_or_else(connection_err); + + let mut proxied_res = Response::new(Body::empty()); + *proxied_res.status_mut() = res.status(); + for (k, v) in res.headers() { + proxied_res.headers_mut().insert(k, v.clone()); + } + + if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS { + tokio::spawn(async move { + let (s_req, s_res) = + tokio::join!(hyper::upgrade::on(&mut req), hyper::upgrade::on(&mut res)); + + if let (Ok(mut s_req), Ok(mut s_res)) = (s_req, s_res) { + let _ = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await; + } + }); + } + + proxied_res +} + +fn connection_err(err: hyper::Error) -> Response { + Response::builder() + .status(503) + .body(Body::from(format!( + "Error connecting to agent host: {err:?}" + ))) + .unwrap() +} diff --git a/cli/src/tunnels/control_server.rs b/cli/src/tunnels/control_server.rs index 74fd247cfcde5..614c05efd9000 100644 --- a/cli/src/tunnels/control_server.rs +++ b/cli/src/tunnels/control_server.rs @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ use crate::async_pipe::get_socket_rw_stream; -use crate::constants::{CONTROL_PORT, PRODUCT_NAME_LONG}; +use crate::constants::{AGENT_HOST_PORT, CONTROL_PORT, PRODUCT_NAME_LONG}; use crate::log; use crate::msgpack_rpc::{new_msgpack_rpc, start_msgpack_rpc, MsgPackCodec, MsgPackSerializer}; use crate::options::Quality; @@ -44,6 +44,9 @@ use std::time::Instant; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, DuplexStream}; use tokio::sync::{mpsc, Mutex}; +use super::agent_host::{ + handle_request as handle_agent_host_request, AgentHostConfig, AgentHostManager, +}; use super::challenge::{create_challenge, sign_challenge, verify_challenge}; use super::code_server::{ download_cli_into_cache, AnyCodeServer, CodeServerArgs, ServerBuilder, ServerParamsRaw, @@ -182,10 +185,46 @@ pub async fn serve( mut shutdown_rx: Barrier, ) -> Result { let mut port = tunnel.add_port_direct(CONTROL_PORT).await?; + let mut agent_host_port = tunnel.add_port_direct(AGENT_HOST_PORT).await?; let mut forwarding = PortForwardingProcessor::new(); let (tx, mut rx) = mpsc::channel::(4); let (exit_barrier, signal_exit) = new_barrier(); + // Set up the agent host manager for on-demand server start on AGENT_HOST_PORT + let agent_host_manager = AgentHostManager::new( + log.clone(), + platform, + launcher_paths.server_cache.clone(), + Arc::new(ReqwestSimpleHttp::new()), + AgentHostConfig { + server_data_dir: code_server_args.server_data_dir.clone(), + without_connection_token: true, + connection_token: None, + connection_token_file: None, + }, + ); + + // Eagerly resolve the latest version and start background updates + if option_env!("VSCODE_CLI_OVERRIDE_SERVER_PATH").is_none() { + let mgr = agent_host_manager.clone(); + let log_for_init = log.clone(); + tokio::spawn(async move { + match mgr.get_latest_release().await { + Ok(release) => { + if let Err(e) = mgr.ensure_downloaded(&release).await { + warning!(log_for_init, "Error downloading latest server: {}", e); + } + } + Err(e) => warning!(log_for_init, "Error resolving latest version: {}", e), + } + }); + + let mgr = agent_host_manager.clone(); + tokio::spawn(async move { + mgr.run_update_loop().await; + }); + } + if !code_server_args.install_extensions.is_empty() { info!( log, @@ -210,6 +249,7 @@ pub async fn serve( tokio::select! { Ok(reason) = shutdown_rx.wait() => { info!(log, "Shutting down: {}", reason); + agent_host_manager.kill_running_server().await; drop(signal_exit); return Ok(ServerTermination { next: match reason { @@ -221,6 +261,7 @@ pub async fn serve( }, c = rx.recv() => { if let Some(ServerSignal::Respawn) = c { + agent_host_manager.kill_running_server().await; drop(signal_exit); return Ok(ServerTermination { next: Next::Respawn, @@ -231,6 +272,25 @@ pub async fn serve( Some(w) = forwarding.recv() => { forwarding.process(w, &mut tunnel).await; }, + Some(socket) = agent_host_port.recv() => { + let mgr = agent_host_manager.clone(); + let ah_log = log.clone(); + tokio::spawn(async move { + debug!(ah_log, "Serving new agent host connection"); + let rw = socket.into_rw(); + let svc = hyper::service::service_fn(move |req| { + let mgr = mgr.clone(); + async move { handle_agent_host_request(mgr, req).await } + }); + if let Err(e) = hyper::server::conn::Http::new() + .serve_connection(rw, svc) + .with_upgrades() + .await + { + debug!(ah_log, "Agent host connection ended: {:?}", e); + } + }); + }, l = port.recv() => { let socket = match l { Some(p) => p, diff --git a/cli/src/tunnels/port_forwarder.rs b/cli/src/tunnels/port_forwarder.rs index b05ae95ae40e6..5e54b07fc68bc 100644 --- a/cli/src/tunnels/port_forwarder.rs +++ b/cli/src/tunnels/port_forwarder.rs @@ -8,7 +8,7 @@ use std::collections::HashSet; use tokio::sync::{mpsc, oneshot}; use crate::{ - constants::CONTROL_PORT, + constants::{AGENT_HOST_PORT, CONTROL_PORT}, util::errors::{AnyError, CannotForwardControlPort, ServerHasClosed}, }; @@ -72,7 +72,7 @@ impl PortForwardingProcessor { port: u16, tunnel: &mut ActiveTunnel, ) -> Result<(), AnyError> { - if port == CONTROL_PORT { + if port == CONTROL_PORT || port == AGENT_HOST_PORT { return Err(CannotForwardControlPort().into()); } @@ -87,7 +87,7 @@ impl PortForwardingProcessor { privacy: PortPrivacy, tunnel: &mut ActiveTunnel, ) -> Result { - if port == CONTROL_PORT { + if port == CONTROL_PORT || port == AGENT_HOST_PORT { return Err(CannotForwardControlPort().into()); } diff --git a/eslint.config.js b/eslint.config.js index 529f428e7e79c..3bd1680172b44 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1499,6 +1499,8 @@ export default tseslint.config( 'when': 'hasNode', 'allow': [ '@github/copilot-sdk', + '@microsoft/dev-tunnels-contracts', + '@microsoft/dev-tunnels-management', '@parcel/watcher', '@vscode/sqlite3', '@vscode/vscode-languagedetection', diff --git a/extensions/copilot/.npmrc b/extensions/copilot/.npmrc index b6f27f1359546..e2aeb544ea3cb 100644 --- a/extensions/copilot/.npmrc +++ b/extensions/copilot/.npmrc @@ -1 +1,3 @@ engine-strict=true +legacy-peer-deps="true" +timeout=180000 diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 4a64e13bab590..f35da6f7b9d13 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -11331,6 +11331,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -13562,6 +13563,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -14423,7 +14425,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "node_modules/js-yaml": { "version": "4.1.1", @@ -15546,6 +15549,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -18585,6 +18589,7 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index d3ef77a7a264b..65eea1abec7f2 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2821,13 +2821,6 @@ "icon": "$(git-pull-request-draft)", "category": "GitHub Copilot" }, - { - "command": "github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR", - "title": "%github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR%", - "enablement": "!chatSessionRequestInProgress", - "icon": "$(git-pull-request)", - "category": "GitHub Copilot" - }, { "command": "github.copilot.chat.copilotCLI.addFileReference", "title": "%github.copilot.command.chat.copilotCLI.addFileReference%", @@ -3977,6 +3970,15 @@ "experimental" ] }, + "github.copilot.chat.exploreAgent.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%github.copilot.config.exploreAgent.enabled%", + "tags": [ + "experimental", + "onExp" + ] + }, "github.copilot.chat.exploreAgent.model": { "type": "string", "default": "", @@ -4011,6 +4013,18 @@ ], "description": "%github.copilot.config.projectSetupInfoSkill.enabled%" }, + "github.copilot.chat.debug.promptOverrideString": { + "type": [ + "string", + "null" + ], + "default": null, + "markdownDescription": "YAML string that overrides the system prompt and/or tool descriptions sent to the model. When both this setting and `github.copilot.chat.debug.promptOverrideFile` are configured, this setting takes precedence.\n\n**Note**: This is an advanced debugging setting.", + "tags": [ + "advanced", + "experimental" + ] + }, "github.copilot.chat.debug.promptOverrideFile": { "type": [ "string", @@ -4850,11 +4864,6 @@ "command": "github.copilot.chat.checkoutPullRequestReroute", "when": "chatSessionType == copilot-cloud-agent && !github.vscode-pull-request-github.activated && gitOpenRepositoryCount != 0", "group": "navigation@0" - }, - { - "command": "github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR", - "when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasPullRequest", - "group": "navigation@9" } ], "chat/input/editing/sessionApplyActions": [ @@ -5218,10 +5227,6 @@ "command": "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR", "when": "false" }, - { - "command": "github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR", - "when": "false" - }, { "command": "github.copilot.chat.checkoutPullRequestReroute", "when": "false" diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 3e6198dd140e8..2396a9723d68f 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -329,6 +329,7 @@ "github.copilot.config.implementAgent.model": "Override the language model used when starting implementation from the Plan agent's handoff. Use the format `Model Name (vendor)` (e.g., `GPT-5 (copilot)`). Leave empty to use the default model.", "github.copilot.config.askAgent.additionalTools": "Additional tools to enable for the Ask agent, on top of built-in read-only tools. Use fully-qualified tool names (e.g., `github/issue_read`, `mcp_server/tool_name`).", "github.copilot.config.askAgent.model": "Override the language model used by the Ask agent. Leave empty to use the default model.", + "github.copilot.config.exploreAgent.enabled": "Enable the Explore (Code Research) subagent.", "github.copilot.config.exploreAgent.model": "Override the language model used by the Explore subagent. Defaults to a fast, small model. Leave empty to use the built-in fallback list.", "copilot.toolSet.editing.description": "Edit files in your workspace", "copilot.toolSet.read.description": "Read files in your workspace", @@ -460,7 +461,6 @@ "github.copilot.chat.createPullRequestCopilotCLIAgentSession.createPR": "Create Pull Request", "github.copilot.chat.createPullRequestCopilotCLIAgentSession.updatePR": "Sync Pull Request", "github.copilot.chat.createDraftPullRequestCopilotCLIAgentSession.createDraftPR": "Create Draft Pull Request", - "github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR": "Open Pull Request", "github.copilot.command.checkoutPullRequestReroute.title": "Checkout", "github.copilot.command.cloudSessions.openRepository.title": "Browse repositories...", "github.copilot.command.cloudSessions.clearCaches.title": "Clear Cloud Agent Caches", diff --git a/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts b/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts index 59d4cc81e73d3..e51bd41fd2662 100644 --- a/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts +++ b/extensions/copilot/src/extension/agents/vscode-node/promptFileContrib.ts @@ -5,7 +5,8 @@ import * as vscode from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; -import { Disposable, MutableDisposable } from '../../../util/vs/base/common/lifecycle'; +import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { combinedDisposable, Disposable, MutableDisposable } from '../../../util/vs/base/common/lifecycle'; import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IExtensionContribution } from '../../common/contributions'; @@ -22,6 +23,7 @@ export class PromptFileContribution extends Disposable implements IExtensionCont constructor( @IInstantiationService instantiationService: IInstantiationService, @IConfigurationService configurationService: IConfigurationService, + @IExperimentationService experimentationService: IExperimentationService, ) { super(); @@ -62,8 +64,25 @@ export class PromptFileContribution extends Disposable implements IExtensionCont this._register(vscode.chat.registerCustomAgentProvider(askProvider)); // Register Explore agent provider for code research subagent - const exploreProvider = instantiationService.createInstance(ExploreAgentProvider); - this._register(vscode.chat.registerCustomAgentProvider(exploreProvider)); + const exploreProviderRegistration = this._register(new MutableDisposable()); + const updateExploreProvider = () => { + const isEnabled = configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, experimentationService); + if (isEnabled) { + if (!exploreProviderRegistration.value) { + const provider = instantiationService.createInstance(ExploreAgentProvider); + const registration = vscode.chat.registerCustomAgentProvider(provider); + exploreProviderRegistration.value = combinedDisposable(registration, provider); + } + } else { + exploreProviderRegistration.clear(); + } + }; + updateExploreProvider(); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ConfigKey.ExploreAgentEnabled.fullyQualifiedId)) { + updateExploreProvider(); + } + })); } // Register instructions provider diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts index 13c0d9b767287..e44674a40cab6 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeMessageDispatch.ts @@ -3,15 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { SDKAssistantMessage, SDKCompactBoundaryMessage, SDKMessage, SDKResultMessage, SDKUserMessage, SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk'; +import type { SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookProgressMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKMessage, SDKResultMessage, SDKUserMessage, SDKUserMessageReplay } from '@anthropic-ai/claude-agent-sdk'; import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools'; import type Anthropic from '@anthropic-ai/sdk'; import * as l10n from '@vscode/l10n'; import type * as vscode from 'vscode'; +import { vBoolean, vLiteral, vObj, vString, type ValidatorType } from '../../../../platform/configuration/common/validator'; import { ILogService } from '../../../../platform/log/common/logService'; -import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, type ISpanHandle, SpanKind, SpanStatusCode, truncateForOTel } from '../../../../platform/otel/common/index'; +import { CopilotChatAttr, GenAiAttr, GenAiOperationName, IOTelService, SpanKind, SpanStatusCode, truncateForOTel, type ISpanHandle } from '../../../../platform/otel/common/index'; import { ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseThinkingProgressPart } from '../../../../vscodeTypes'; +import { ChatResponseThinkingProgressPart, type ChatHookType } from '../../../../vscodeTypes'; import { ToolName } from '../../../tools/common/toolNames'; import { IToolsService } from '../../../tools/common/toolsService'; import { ClaudeToolNames } from './claudeTools'; @@ -30,6 +31,7 @@ export interface MessageHandlerRequestContext { export interface MessageHandlerState { readonly unprocessedToolCalls: Map; readonly otelToolSpans: Map; + readonly otelHookSpans: Map; } export interface MessageHandlerResult { @@ -67,20 +69,34 @@ export const ALL_KNOWN_MESSAGE_KEYS = new Set([ 'user', 'result', 'stream_event', + // TODO: Show `tool_progress` — has `tool_name` and `elapsed_time_seconds` for live tool status + // low pri, where would we show this? 'tool_progress', + // TODO: Show `tool_use_summary` — has `summary` text describing tool execution results + // low pri, where would we show this? 'tool_use_summary', + // TODO: Show `auth_status` — has `output` lines and `error` for auth failures 'auth_status', + // TODO: Show `rate_limit_event` — has `rate_limit_info.status` (allowed_warning | rejected) and reset time 'rate_limit_event', + // TODO: Show `prompt_suggestion` — has `suggestion` text for follow-up prompts + // low pri, follow ups are dead 'prompt_suggestion', 'system:init', 'system:compact_boundary', 'system:status', + // TODO: Show `system:api_retry` — has `error`, `attempt`, `max_retries` for retry visibility + 'system:api_retry', + // TODO: Show `system:local_command_output` — has `content` text from local slash commands 'system:local_command_output', 'system:hook_started', 'system:hook_progress', 'system:hook_response', + // TODO: Show `system:task_notification` — has `summary` and `status` for subagent completion 'system:task_notification', + // TODO: Show `system:task_started` — has `description` and `prompt` for subagent launch 'system:task_started', + // TODO: Show `system:task_progress` — has `description` and `summary` for subagent progress 'system:task_progress', 'system:files_persisted', 'system:elicitation_complete', @@ -274,6 +290,229 @@ export function handleCompactBoundary( request.stream.markdown(`*${l10n.t('Conversation compacted')}*`); } +export function handleHookStarted( + message: SDKHookStartedMessage, + accessor: ServicesAccessor, + sessionId: string, + state: MessageHandlerState, +): void { + const otelService = accessor.get(IOTelService); + const span = otelService.startSpan(`user_hook ${message.hook_event}:${message.hook_name}`, { + kind: SpanKind.INTERNAL, + attributes: { + [GenAiAttr.OPERATION_NAME]: GenAiOperationName.EXECUTE_HOOK, + 'copilot_chat.hook_type': message.hook_event, + 'copilot_chat.hook_command': message.hook_name, + 'copilot_chat.hook_id': message.hook_id, + [CopilotChatAttr.CHAT_SESSION_ID]: sessionId, + }, + }); + state.otelHookSpans.set(message.hook_id, span); +} + +// #region Hook JSON output validator + +/** + * Validator for structured JSON output from hooks (exit code 0 only). + * + * Hooks can return JSON with these fields: + * - `continue`: if false, stops processing entirely + * - `stopReason`: message shown to user when `continue` is false + * - `systemMessage`: warning shown to user + * - `decision`: "block" to prevent the operation + * - `reason`: explanation when `decision` is "block" + * + * @see https://code.claude.com/docs/en/hooks.md + */ +const vHookJsonOutput = vObj({ + continue: vBoolean(), + stopReason: vString(), + systemMessage: vString(), + decision: vLiteral('block'), + reason: vString(), +}); + +export type HookJsonOutput = ValidatorType; + +/** + * Parses JSON output from a hook's stdout. + * Returns the validated fields, or undefined if parsing/validation fails. + * Fields that are missing from the JSON are simply absent from the result. + */ +export function parseHookJsonOutput(stdout: string): Partial | undefined { + let raw: unknown; + try { + raw = JSON.parse(stdout); + } catch { + return undefined; + } + + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return undefined; + } + + // Use the validator to extract known fields with type safety. + // vObj skips missing optional fields, so partial results are expected. + const result = vHookJsonOutput.validate(raw); + if (result.error) { + // Validation error means some present field had the wrong type — + // extract what we can by validating each field individually. + const obj = raw as Record; + const partial: Partial = {}; + + const continueResult = vBoolean().validate(obj['continue']); + if (!continueResult.error) { + partial.continue = continueResult.content; + } + const stopReasonResult = vString().validate(obj['stopReason']); + if (!stopReasonResult.error) { + partial.stopReason = stopReasonResult.content; + } + const systemMessageResult = vString().validate(obj['systemMessage']); + if (!systemMessageResult.error) { + partial.systemMessage = systemMessageResult.content; + } + const decisionResult = vLiteral('block').validate(obj['decision']); + if (!decisionResult.error) { + partial.decision = decisionResult.content; + } + const reasonResult = vString().validate(obj['reason']); + if (!reasonResult.error) { + partial.reason = reasonResult.content; + } + + return Object.keys(partial).length > 0 ? partial : undefined; + } + + return result.content; +} + +// #endregion + +/** + * Formats a localized error message for a failed hook. + * @param errorMessage The error message from the hook + * @returns A localized error message string + * @todo use a common function with: https://github.com/microsoft/vscode-copilot-chat/blob/9a9461734da42f28e4e2d0b975ebeae6162e9b4c/src/extension/intents/node/hookResultProcessor.ts#L142 + */ +function formatHookErrorMessage(errorMessage: string): string { + if (errorMessage) { + return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details. \nError message: {0}', errorMessage); + } + return l10n.t('A hook prevented chat from continuing. Please check the GitHub Copilot Chat Hooks output channel for more details.'); +} + + +export function handleHookProgress( + message: SDKHookProgressMessage, + accessor: ServicesAccessor, + request: MessageHandlerRequestContext, +): void { + const logService = accessor.get(ILogService); + // TODO: can we map these types better + const hookType = message.hook_event as ChatHookType; + const progressText = message.stdout || message.stderr; + + logService.trace(`[ClaudeMessageDispatch] Hook progress "${message.hook_name}" (${message.hook_event}): ${progressText}`); + + if (progressText) { + request.stream.hookProgress(hookType, undefined, progressText); + } +} + +export function handleHookResponse( + message: SDKHookResponseMessage, + accessor: ServicesAccessor, + request: MessageHandlerRequestContext, + state: MessageHandlerState, +): void { + const logService = accessor.get(ILogService); + // TODO: can we map these types better + const hookType = message.hook_event as ChatHookType; + + // #region OTel span + const span = state.otelHookSpans.get(message.hook_id); + if (span) { + if (message.outcome === 'error') { + span.setStatus(SpanStatusCode.ERROR, message.stderr || message.output); + } else if (message.outcome === 'cancelled') { + span.setStatus(SpanStatusCode.ERROR, 'cancelled'); + } else { + span.setStatus(SpanStatusCode.OK); + } + if (message.exit_code !== undefined) { + span.setAttribute('copilot_chat.hook_exit_code', message.exit_code); + } + if (message.output) { + span.setAttribute('copilot_chat.hook_output', truncateForOTel(message.output)); + } + span.end(); + state.otelHookSpans.delete(message.hook_id); + } + // #endregion + + // Cancelled — log only, no user-facing output + if (message.outcome === 'cancelled') { + logService.trace(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) was cancelled`); + return; + } + + // Exit code 2 — blocking error (stderr is the message, JSON ignored) + if (message.exit_code === 2) { + const errorMessage = message.stderr || message.output; + logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) blocking error: ${errorMessage}`); + request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage)); + return; + } + + // Other non-zero exit codes — non-blocking warning + if (message.exit_code !== undefined && message.exit_code !== 0) { + const warningMessage = message.stderr || message.output; + const loggedMessage = warningMessage || l10n.t('Exit Code: {0}', message.exit_code); + logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) non-blocking error (exit ${message.exit_code}): ${loggedMessage}`); + if (warningMessage) { + request.stream.hookProgress(hookType, undefined, warningMessage); + } + return; + } + + // Outcome 'error' without a specific exit code — treat as blocking error + if (message.outcome === 'error') { + const errorMessage = message.stderr || message.output; + logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" (${message.hook_event}) failed: ${errorMessage}`); + request.stream.hookProgress(hookType, formatHookErrorMessage(errorMessage)); + return; + } + + // Exit code 0 (or undefined with success outcome) — parse JSON from stdout + if (!message.stdout) { + return; + } + + const parsed = parseHookJsonOutput(message.stdout); + if (!parsed) { + logService.warn(`[ClaudeMessageDispatch] Hook "${message.hook_name}" returned non-JSON output`); + return; + } + + // Handle `decision: "block"` with `reason` + if (parsed.decision === 'block') { + request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.reason ?? '')); + return; + } + + // Handle `continue: false` with optional `stopReason` + if (parsed.continue === false) { + request.stream.hookProgress(hookType, formatHookErrorMessage(parsed.stopReason ?? '')); + return; + } + + // Handle `systemMessage` — shown as a warning + if (parsed.systemMessage) { + request.stream.hookProgress(hookType, undefined, parsed.systemMessage); + } +} + export function handleResultMessage( message: SDKResultMessage, request: MessageHandlerRequestContext, @@ -324,6 +563,18 @@ export function dispatchMessage( handleCompactBoundary(message, request); return; } + if (message.subtype === 'hook_started') { + handleHookStarted(message, accessor, sessionId, state); + return; + } + if (message.subtype === 'hook_progress') { + handleHookProgress(message, accessor, request); + return; + } + if (message.subtype === 'hook_response') { + handleHookResponse(message, accessor, request, state); + return; + } break; } diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts index 17768070e2c43..5e28e03fc89bc 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/test/claudeMessageDispatch.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { NonNullableUsage, SDKAssistantMessage, SDKCompactBoundaryMessage, SDKResultError, SDKResultSuccess, SDKStatusMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { NonNullableUsage, SDKAssistantMessage, SDKCompactBoundaryMessage, SDKHookProgressMessage, SDKHookResponseMessage, SDKHookStartedMessage, SDKResultError, SDKResultSuccess, SDKStatusMessage, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import type Anthropic from '@anthropic-ai/sdk'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type * as vscode from 'vscode'; @@ -18,12 +18,16 @@ import { dispatchMessage, handleAssistantMessage, handleCompactBoundary, + handleHookProgress, + handleHookResponse, + handleHookStarted, handleResultMessage, handleUserMessage, KnownClaudeError, MessageHandlerRequestContext, MessageHandlerState, messageKey, + parseHookJsonOutput, SYNTHETIC_MODEL_ID, } from '../claudeMessageDispatch'; import { ClaudeToolNames } from '../claudeTools'; @@ -74,7 +78,8 @@ function createRequestContext(): MessageHandlerRequestContext { markdown: vi.fn(), push: vi.fn(), progress: vi.fn(), - } as Pick as vscode.ChatResponseStream, + hookProgress: vi.fn(), + } as Pick as vscode.ChatResponseStream, toolInvocationToken: {} as vscode.ChatParticipantToolToken, token: { isCancellationRequested: false } as vscode.CancellationToken, }; @@ -84,6 +89,7 @@ function createState(): MessageHandlerState { return { unprocessedToolCalls: new Map(), otelToolSpans: new Map(), + otelHookSpans: new Map(), }; } @@ -224,6 +230,57 @@ function makeStatusMessage(): SDKStatusMessage { }; } +function makeHookStarted(hookId = 'hook-1', hookName = 'my-hook', hookEvent = 'PreToolUse'): SDKHookStartedMessage { + return { + type: 'system', + subtype: 'hook_started', + hook_id: hookId, + hook_name: hookName, + hook_event: hookEvent, + uuid: TEST_UUID, + session_id: TEST_SESSION, + }; +} + +function makeHookResponse( + hookId = 'hook-1', + outcome: 'success' | 'error' | 'cancelled' = 'success', + overrides: Partial> = {}, +): SDKHookResponseMessage { + return { + type: 'system', + subtype: 'hook_response', + hook_id: hookId, + hook_name: overrides.hook_name ?? 'my-hook', + hook_event: overrides.hook_event ?? 'PreToolUse', + output: overrides.output ?? '', + stdout: overrides.stdout ?? '', + stderr: overrides.stderr ?? '', + exit_code: overrides.exit_code, + outcome, + uuid: TEST_UUID, + session_id: TEST_SESSION, + }; +} + +function makeHookProgress( + hookId = 'hook-1', + overrides: Partial> = {}, +): SDKHookProgressMessage { + return { + type: 'system', + subtype: 'hook_progress', + hook_id: hookId, + hook_name: overrides.hook_name ?? 'my-hook', + hook_event: overrides.hook_event ?? 'PreToolUse', + stdout: overrides.stdout ?? '', + stderr: overrides.stderr ?? '', + output: overrides.output ?? '', + uuid: TEST_UUID, + session_id: TEST_SESSION, + }; +} + // #endregion // #region messageKey @@ -477,6 +534,360 @@ describe('handleCompactBoundary', () => { // #endregion +// #region handleHookStarted / handleHookResponse + +describe('handleHookStarted', () => { + let services: TestServices; + let accessor: ServicesAccessor; + let state: MessageHandlerState; + + beforeEach(() => { + services = createTestServices(); + accessor = createAccessor(services); + state = createState(); + }); + + it('creates an OTel span and stores it by hook_id', () => { + const startSpanSpy = vi.spyOn(services.otelService, 'startSpan'); + handleHookStarted(makeHookStarted('hook-42', 'lint-check', 'PreToolUse'), accessor, TEST_SESSION_ID, state); + + expect(startSpanSpy).toHaveBeenCalledWith( + 'user_hook PreToolUse:lint-check', + expect.objectContaining({ attributes: expect.any(Object) }), + ); + expect(state.otelHookSpans.has('hook-42')).toBe(true); + }); +}); + +describe('handleHookResponse', () => { + let services: TestServices; + let accessor: ServicesAccessor; + let request: MessageHandlerRequestContext; + let state: MessageHandlerState; + + beforeEach(() => { + services = createTestServices(); + accessor = createAccessor(services); + request = createRequestContext(); + state = createState(); + }); + + it('ends the OTel span with OK on success', () => { + const mockSpan = createMockSpan(); + state.otelHookSpans.set('hook-1', mockSpan); + + handleHookResponse(makeHookResponse('hook-1', 'success'), accessor, request, state); + + expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything()); // SpanStatusCode.OK + expect(mockSpan.end).toHaveBeenCalled(); + expect(state.otelHookSpans.has('hook-1')).toBe(false); + }); + + it('ends the OTel span with ERROR on failure and surfaces error via hookProgress', () => { + const mockSpan = createMockSpan(); + state.otelHookSpans.set('hook-1', mockSpan); + + handleHookResponse( + makeHookResponse('hook-1', 'error', { stderr: 'lint failed', hook_name: 'lint-check', hook_event: 'PreToolUse' }), + accessor, request, state, + ); + + expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything(), 'lint failed'); + expect(mockSpan.end).toHaveBeenCalled(); + expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', expect.stringContaining('lint failed')); + expect(request.stream.markdown).not.toHaveBeenCalled(); + }); + + it('does not surface anything to user on success with no stdout', () => { + const mockSpan = createMockSpan(); + state.otelHookSpans.set('hook-1', mockSpan); + + handleHookResponse(makeHookResponse('hook-1', 'success'), accessor, request, state); + + expect(request.stream.hookProgress).not.toHaveBeenCalled(); + expect(request.stream.markdown).not.toHaveBeenCalled(); + }); + + it('handles cancelled outcome — log only, no hookProgress', () => { + const mockSpan = createMockSpan(); + state.otelHookSpans.set('hook-1', mockSpan); + + handleHookResponse(makeHookResponse('hook-1', 'cancelled'), accessor, request, state); + + expect(mockSpan.setStatus).toHaveBeenCalledWith(expect.anything(), 'cancelled'); + expect(mockSpan.end).toHaveBeenCalled(); + expect(request.stream.hookProgress).not.toHaveBeenCalled(); + }); + + it('handles response without a matching started span gracefully', () => { + // No span in otelHookSpans — should not throw + handleHookResponse( + makeHookResponse('nonexistent', 'error', { stderr: 'some error', hook_name: 'my-hook', hook_event: 'PreToolUse' }), + accessor, request, state, + ); + // Still surfaces the error via hookProgress + expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', expect.stringContaining('some error')); + }); + + // #region Exit code handling + + it('exit code 2 — blocking error via hookProgress with stderr', () => { + handleHookResponse( + makeHookResponse('hook-1', 'error', { exit_code: 2, stderr: 'blocked!', hook_event: 'Stop' }), + accessor, request, state, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('Stop', expect.stringContaining('blocked!')); + }); + + it('exit code 2 — ignores JSON in stdout', () => { + handleHookResponse( + makeHookResponse('hook-1', 'error', { + exit_code: 2, + stderr: 'real error', + stdout: '{"decision": "block", "reason": "should be ignored"}', + hook_event: 'PostToolUse', + }), + accessor, request, state, + ); + // Should use stderr, not JSON + expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', expect.stringContaining('real error')); + }); + + it('other non-zero exit codes — non-blocking warning', () => { + handleHookResponse( + makeHookResponse('hook-1', 'error', { exit_code: 1, stderr: 'warning text', hook_event: 'PreToolUse' }), + accessor, request, state, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'warning text'); + }); + + it('other non-zero exit codes without stderr — no hookProgress', () => { + handleHookResponse( + makeHookResponse('hook-1', 'error', { exit_code: 1, hook_event: 'PreToolUse' }), + accessor, request, state, + ); + expect(request.stream.hookProgress).not.toHaveBeenCalled(); + }); + + // #endregion + + // #region JSON output parsing (exit code 0) + + it('exit code 0 — JSON with continue:false calls hookProgress with stopReason', () => { + handleHookResponse( + makeHookResponse('hook-1', 'success', { + exit_code: 0, + stdout: JSON.stringify({ continue: false, stopReason: 'Build failed' }), + hook_event: 'UserPromptSubmit', + }), + accessor, request, state, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('UserPromptSubmit', expect.stringContaining('Build failed')); + }); + + it('exit code 0 — JSON with continue:false and no stopReason uses empty string', () => { + handleHookResponse( + makeHookResponse('hook-1', 'success', { + exit_code: 0, + stdout: JSON.stringify({ continue: false }), + hook_event: 'Stop', + }), + accessor, request, state, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('Stop', expect.any(String)); + }); + + it('exit code 0 — JSON with decision:block calls hookProgress with reason', () => { + handleHookResponse( + makeHookResponse('hook-1', 'success', { + exit_code: 0, + stdout: JSON.stringify({ decision: 'block', reason: 'Tests must pass' }), + hook_event: 'PostToolUse', + }), + accessor, request, state, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', expect.stringContaining('Tests must pass')); + }); + + it('exit code 0 — JSON with systemMessage shows warning via hookProgress', () => { + handleHookResponse( + makeHookResponse('hook-1', 'success', { + exit_code: 0, + stdout: JSON.stringify({ systemMessage: 'Watch out for side effects' }), + hook_event: 'PreToolUse', + }), + accessor, request, state, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'Watch out for side effects'); + }); + + it('exit code 0 — non-JSON stdout logs warning, no hookProgress', () => { + const warnSpy = vi.spyOn(services.logService, 'warn'); + handleHookResponse( + makeHookResponse('hook-1', 'success', { + exit_code: 0, + stdout: 'not valid json {', + hook_event: 'PreToolUse', + }), + accessor, request, state, + ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('non-JSON output')); + expect(request.stream.hookProgress).not.toHaveBeenCalled(); + }); + + it('exit code 0 — empty stdout means success, no hookProgress', () => { + handleHookResponse( + makeHookResponse('hook-1', 'success', { exit_code: 0, stdout: '' }), + accessor, request, state, + ); + expect(request.stream.hookProgress).not.toHaveBeenCalled(); + }); + + it('exit code 0 — JSON with continue:true and no systemMessage is silent', () => { + handleHookResponse( + makeHookResponse('hook-1', 'success', { + exit_code: 0, + stdout: JSON.stringify({ continue: true }), + hook_event: 'PreToolUse', + }), + accessor, request, state, + ); + expect(request.stream.hookProgress).not.toHaveBeenCalled(); + }); + + // #endregion +}); + +// #endregion + +// #region handleHookProgress + +describe('handleHookProgress', () => { + let services: TestServices; + let accessor: ServicesAccessor; + let request: MessageHandlerRequestContext; + + beforeEach(() => { + services = createTestServices(); + accessor = createAccessor(services); + request = createRequestContext(); + }); + + it('shows stdout via hookProgress as system message', () => { + handleHookProgress( + makeHookProgress('hook-1', { stdout: 'Running lint...', hook_event: 'PreToolUse' }), + accessor, request, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('PreToolUse', undefined, 'Running lint...'); + }); + + it('falls back to stderr when stdout is empty', () => { + handleHookProgress( + makeHookProgress('hook-1', { stderr: 'warning output', hook_event: 'PostToolUse' }), + accessor, request, + ); + expect(request.stream.hookProgress).toHaveBeenCalledWith('PostToolUse', undefined, 'warning output'); + }); + + it('does not call hookProgress when both stdout and stderr are empty', () => { + handleHookProgress( + makeHookProgress('hook-1'), + accessor, request, + ); + expect(request.stream.hookProgress).not.toHaveBeenCalled(); + }); + + it('trace-logs progress output', () => { + const traceSpy = vi.spyOn(services.logService, 'trace'); + handleHookProgress( + makeHookProgress('hook-1', { stdout: 'progress text', hook_name: 'my-hook', hook_event: 'PreToolUse' }), + accessor, request, + ); + expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('Hook progress')); + expect(traceSpy).toHaveBeenCalledWith(expect.stringContaining('progress text')); + }); +}); + +// #endregion + +// #region parseHookJsonOutput + +describe('parseHookJsonOutput', () => { + it('parses valid JSON with all fields', () => { + const result = parseHookJsonOutput(JSON.stringify({ + continue: false, + stopReason: 'Build failed', + systemMessage: 'Warning', + decision: 'block', + reason: 'Not allowed', + })); + expect(result).toEqual({ + continue: false, + stopReason: 'Build failed', + systemMessage: 'Warning', + decision: 'block', + reason: 'Not allowed', + }); + }); + + it('parses JSON with only some fields', () => { + const result = parseHookJsonOutput(JSON.stringify({ continue: false })); + expect(result).toEqual({ continue: false }); + }); + + it('returns undefined for non-JSON string', () => { + expect(parseHookJsonOutput('not json')).toBeUndefined(); + }); + + it('returns undefined for JSON null', () => { + expect(parseHookJsonOutput('null')).toBeUndefined(); + }); + + it('returns undefined for JSON array', () => { + expect(parseHookJsonOutput('[]')).toBeUndefined(); + }); + + it('returns undefined for JSON primitive', () => { + expect(parseHookJsonOutput('"hello"')).toBeUndefined(); + }); + + it('ignores fields with wrong types via fallback validation', () => { + const result = parseHookJsonOutput(JSON.stringify({ + continue: 'not-a-boolean', + stopReason: 42, + systemMessage: 'valid string', + })); + expect(result).toEqual({ systemMessage: 'valid string' }); + }); + + it('returns undefined when all fields have wrong types', () => { + const result = parseHookJsonOutput(JSON.stringify({ + continue: 'true', + decision: 'allow', + })); + expect(result).toBeUndefined(); + }); + + it('ignores unknown fields', () => { + const result = parseHookJsonOutput(JSON.stringify({ + continue: true, + unknownField: 'whatever', + })); + expect(result).toEqual({ continue: true }); + }); + + it('rejects decision values other than block', () => { + const result = parseHookJsonOutput(JSON.stringify({ + decision: 'allow', + systemMessage: 'hello', + })); + // decision: 'allow' fails vLiteral('block'), but systemMessage succeeds + expect(result).toEqual({ systemMessage: 'hello' }); + }); +}); + +// #endregion + // #region handleResultMessage describe('handleResultMessage', () => { @@ -517,7 +928,7 @@ describe('ALL_KNOWN_MESSAGE_KEYS', () => { it('contains entries for all system subtype values', () => { const expectedSystemSubtypes = [ - 'init', 'compact_boundary', 'status', 'local_command_output', + 'init', 'compact_boundary', 'status', 'api_retry', 'local_command_output', 'hook_started', 'hook_progress', 'hook_response', 'task_notification', 'task_started', 'task_progress', 'files_persisted', 'elicitation_complete', diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index 7b359f93bfbe8..026842d284cc9 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -441,6 +441,7 @@ export class ClaudeCodeSession extends Disposable { // Pass the permission mode to the SDK permissionMode: this._currentPermissionMode, hooks: this._buildHooks(token), + includeHookEvents: true, mcpServers, settings: { env: { @@ -622,6 +623,7 @@ export class ClaudeCodeSession extends Disposable { */ private async _processMessages(): Promise { const otelToolSpans = new Map(); + const otelHookSpans = new Map(); try { const unprocessedToolCalls = new Map(); for await (const message of this._queryGenerator!) { @@ -655,6 +657,7 @@ export class ClaudeCodeSession extends Disposable { }, { unprocessedToolCalls, otelToolSpans, + otelHookSpans, }); if (result?.requestComplete) { @@ -680,6 +683,11 @@ export class ClaudeCodeSession extends Disposable { span.end(); } otelToolSpans.clear(); + for (const [, span] of otelHookSpans) { + span.setStatus(SpanStatusCode.ERROR, 'session ended before hook completed'); + span.end(); + } + otelHookSpans.clear(); } } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/hooks/loggingHooks.ts b/extensions/copilot/src/extension/chatSessions/claude/node/hooks/loggingHooks.ts index 3f3da404fb332..2db2987d9711b 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/hooks/loggingHooks.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/hooks/loggingHooks.ts @@ -41,30 +41,6 @@ export class NotificationLoggingHook implements HookCallbackMatcher { } registerClaudeHook('Notification', NotificationLoggingHook); -/** - * Logging hook for UserPromptSubmit events. - */ -export class UserPromptSubmitLoggingHook implements HookCallbackMatcher { - public readonly hooks: HookCallback[]; - - constructor( - @ILogService private readonly logService: ILogService, - @IOTelService private readonly otelService: IOTelService, - ) { - this.hooks = [this._handle.bind(this)]; - } - - private async _handle(input: HookInput): Promise { - const hookInput = input as { prompt?: string; session_id: string }; - return withHookOTelSpan(this.otelService, 'UserPromptSubmit', 'UserPromptSubmit', hookInput.session_id, - { prompt: hookInput.prompt }, async () => { - this.logService.trace(`[ClaudeCodeSession] UserPromptSubmit Hook: prompt=${hookInput.prompt}`); - return { continue: true }; - }); - } -} -registerClaudeHook('UserPromptSubmit', UserPromptSubmitLoggingHook); - /** * Logging hook for Stop events. */ diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts index c96803351ee79..66be6e31189cb 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts @@ -95,11 +95,14 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh const repositoryProperties = await this.getRepositoryProperties(sessionId); if (!repositoryProperties) { this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository properties found for session ${sessionId}`); + this.workspaceFolderChanges.set(sessionId, []); return []; } const repository = await this.gitService.getRepository(vscode.Uri.file(repositoryProperties.repositoryPath)); if (!repository?.changes) { + this.logService.warn(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] No repository found for session ${sessionId}`); + this.workspaceFolderChanges.set(sessionId, []); return []; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 1b49a57d1db8a..5df14e19cd14a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -2306,34 +2306,6 @@ export function registerCLIChatCommands( }); })); - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { - const resource = sessionItemOrResource instanceof vscode.Uri - ? sessionItemOrResource - : sessionItemOrResource?.resource; - - if (!resource) { - return; - } - - try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - throw new Error('Open pull request is only supported for v2 worktree sessions'); - } - - if (!worktreeProperties.pullRequestUrl) { - vscode.window.showInformationMessage(l10n.t('No pull request has been created for this session yet. Use "Create Pull Request" first.')); - return; - } - - await vscode.env.openExternal(vscode.Uri.parse(worktreeProperties.pullRequestUrl)); - } catch (error) { - logService.error(`Failed to open pull request: ${error instanceof Error ? error.message : String(error)}`); - vscode.window.showErrorMessage(l10n.t('Failed to open pull request: {0}', error instanceof Error ? error.message : String(error)), { modal: true }); - } - })); - disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToWorktree', async (args?: { worktreeUri?: vscode.Uri; fileUri?: vscode.Uri }) => { logService.trace(`[commitToWorktree] Command invoked, args: ${JSON.stringify(args, null, 2)}`); if (!args?.worktreeUri || !args?.fileUri) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 04a025c079d5a..3928410b0126a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -2567,34 +2567,6 @@ export function registerCLIChatCommands( }); })); - disposableStore.add(vscode.commands.registerCommand('github.copilot.chat.openPullRequestCopilotCLIAgentSession.openPR', async (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) => { - const resource = sessionItemOrResource instanceof vscode.Uri - ? sessionItemOrResource - : sessionItemOrResource?.resource; - - if (!resource) { - return; - } - - try { - const sessionId = SessionIdForCLI.parse(resource); - const worktreeProperties = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - throw new Error('Open pull request is only supported for v2 worktree sessions'); - } - - if (!worktreeProperties.pullRequestUrl) { - vscode.window.showInformationMessage(l10n.t('No pull request has been created for this session yet. Use "Create Pull Request" first.')); - return; - } - - await vscode.env.openExternal(vscode.Uri.parse(worktreeProperties.pullRequestUrl)); - } catch (error) { - logService.error(`Failed to open pull request: ${error instanceof Error ? error.message : String(error)}`); - vscode.window.showErrorMessage(l10n.t('Failed to open pull request: {0}', error instanceof Error ? error.message : String(error)), { modal: true }); - } - })); - disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.commitToWorktree', async (args?: { worktreeUri?: vscode.Uri; fileUri?: vscode.Uri }) => { logService.trace(`[commitToWorktree] Command invoked, args: ${JSON.stringify(args, null, 2)}`); if (!args?.worktreeUri || !args?.fileUri) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index c5a4f5b7f8a87..4bff7a40a55cc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -1154,6 +1154,8 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C owner: pr.repository?.owner?.login, branch: pr.headRefName, baseBranch: pr.baseRefName, + headRefOid: pr.headRefOid, + baseRefOid: pr.baseRefOid, pullRequestUrl: pr.url, pullRequestState: derivePullRequestState(pr), } satisfies { readonly [key: string]: unknown }; diff --git a/extensions/copilot/src/extension/intents/node/promptOverride.ts b/extensions/copilot/src/extension/intents/node/promptOverride.ts index 88a20a9d3a1b2..4f14c912599dd 100644 --- a/extensions/copilot/src/extension/intents/node/promptOverride.ts +++ b/extensions/copilot/src/extension/intents/node/promptOverride.ts @@ -8,14 +8,48 @@ import * as yaml from 'js-yaml'; import type { LanguageModelToolInformation, Uri } from 'vscode'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { ILogService } from '../../../platform/log/common/logService'; +import { URI } from '../../../util/vs/base/common/uri'; interface PromptOverrideConfig { readonly systemPrompt?: string; readonly toolDescriptions?: Record; } -/** Tracks which file URIs have already had a warning logged, to avoid spamming. */ -const warnedFiles = new Set(); +interface PromptOverrideResult { + readonly messages: Raw.ChatMessage[]; + readonly tools: LanguageModelToolInformation[]; +} + +const INLINE_PROMPT_OVERRIDE_SOURCE = 'inlinePromptOverrideString'; + +/** Tracks which override sources have already had a warning logged, to avoid spamming. */ +const warnedSources = new Set(); + +export async function applyConfiguredPromptOverrides( + inlinePromptOverride: string | null, + promptOverrideFile: string | null, + messages: readonly Raw.ChatMessage[], + tools: readonly LanguageModelToolInformation[], + fileSystemService: IFileSystemService, + logService: ILogService, +): Promise { + const normalizedInlinePromptOverride = inlinePromptOverride?.trim(); + const normalizedPromptOverrideFile = promptOverrideFile?.trim(); + + if (normalizedInlinePromptOverride) { + if (normalizedPromptOverrideFile) { + logService.trace('[PromptOverride] Both inline prompt override text and prompt override file are configured; using inline prompt override text'); + } + + return applyPromptOverridesFromString(normalizedInlinePromptOverride, messages, tools, logService); + } + + if (normalizedPromptOverrideFile) { + return applyPromptOverrides(URI.file(normalizedPromptOverrideFile), messages, tools, fileSystemService, logService); + } + + return clonePromptOverrideResult(messages, tools); +} /** * Applies debug prompt overrides from a YAML file. @@ -28,30 +62,70 @@ export async function applyPromptOverrides( tools: readonly LanguageModelToolInformation[], fileSystemService: IFileSystemService, logService: ILogService, -): Promise<{ messages: Raw.ChatMessage[]; tools: LanguageModelToolInformation[] }> { - let config: PromptOverrideConfig; +): Promise { + const key = fileUri.toString(); + let content: string; try { const buffer = await fileSystemService.readFile(fileUri); - const content = new TextDecoder().decode(buffer); + content = new TextDecoder().decode(buffer); + } catch (err) { + logPromptOverrideFailure(logService, key, `Failed to read prompt override file "${key}"`, err); + return clonePromptOverrideResult(messages, tools); + } + + const config = parsePromptOverrideConfig(content, key, `prompt override file "${key}"`, logService); + if (!config) { + return clonePromptOverrideResult(messages, tools); + } + + return applyPromptOverrideConfig(config, messages, tools, logService); +} + + +export function applyPromptOverridesFromString( + content: string, + messages: readonly Raw.ChatMessage[], + tools: readonly LanguageModelToolInformation[], + logService: ILogService, +): PromptOverrideResult { + const config = parsePromptOverrideConfig(content, INLINE_PROMPT_OVERRIDE_SOURCE, `inline prompt override setting "${INLINE_PROMPT_OVERRIDE_SOURCE}"`, logService); + if (!config) { + return clonePromptOverrideResult(messages, tools); + } + + return applyPromptOverrideConfig(config, messages, tools, logService); +} + +function parsePromptOverrideConfig( + content: string, + sourceKey: string, + sourceDescription: string, + logService: ILogService, +): PromptOverrideConfig | undefined { + let config: PromptOverrideConfig; + try { config = yaml.load(content) as PromptOverrideConfig; } catch (err) { - const key = fileUri.toString(); - if (!warnedFiles.has(key)) { - warnedFiles.add(key); - logService.warn(`[PromptOverride] Failed to read or parse YAML file "${key}": ${err}`); - } else { - logService.trace(`[PromptOverride] Failed to read or parse YAML file "${key}": ${err}`); - } - return { messages: [...messages], tools: [...tools] }; + logPromptOverrideFailure(logService, sourceKey, `Failed to parse prompt override from ${sourceDescription}`, err); + return undefined; } - // On successful read, clear any previous warning so a new error is re-surfaced as warn - warnedFiles.delete(fileUri.toString()); + // On successful parsing, clear any previous warning so a new error is re-surfaced as warn. + warnedSources.delete(sourceKey); if (!config || typeof config !== 'object') { - return { messages: [...messages], tools: [...tools] }; + return undefined; } + return config; +} + +function applyPromptOverrideConfig( + config: PromptOverrideConfig, + messages: readonly Raw.ChatMessage[], + tools: readonly LanguageModelToolInformation[], + logService: ILogService, +): PromptOverrideResult { let resultMessages = [...messages]; let resultTools = [...tools]; @@ -68,12 +142,28 @@ export async function applyPromptOverrides( return { messages: resultMessages, tools: resultTools }; } +function clonePromptOverrideResult( + messages: readonly Raw.ChatMessage[], + tools: readonly LanguageModelToolInformation[], +): PromptOverrideResult { + return { messages: [...messages], tools: [...tools] }; +} + +function logPromptOverrideFailure(logService: ILogService, sourceKey: string, message: string, err: unknown): void { + if (!warnedSources.has(sourceKey)) { + warnedSources.add(sourceKey); + logService.warn(`[PromptOverride] ${message}: ${err}`); + } else { + logService.trace(`[PromptOverride] ${message}: ${err}`); + } +} + /** * Resets the internal warning deduplication state. * Exported for testing only. */ export function resetPromptOverrideWarnings(): void { - warnedFiles.clear(); + warnedSources.clear(); } function applySystemPromptOverride(messages: Raw.ChatMessage[], systemPrompt: string): Raw.ChatMessage[] { diff --git a/extensions/copilot/src/extension/intents/node/test/promptOverride.spec.ts b/extensions/copilot/src/extension/intents/node/test/promptOverride.spec.ts index a8687afbf6dea..afc99a80ced06 100644 --- a/extensions/copilot/src/extension/intents/node/test/promptOverride.spec.ts +++ b/extensions/copilot/src/extension/intents/node/test/promptOverride.spec.ts @@ -9,7 +9,7 @@ import type { LanguageModelToolInformation } from 'vscode'; import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; import { TestLogService } from '../../../../platform/testing/common/testLogService'; import { URI } from '../../../../util/vs/base/common/uri'; -import { applyPromptOverrides, resetPromptOverrideWarnings } from '../promptOverride'; +import { applyConfiguredPromptOverrides, applyPromptOverrides, applyPromptOverridesFromString, resetPromptOverrideWarnings } from '../promptOverride'; function makeMessages(...specs: Array<{ role: Raw.ChatRole; content: string }>): Raw.ChatMessage[] { return specs.map(s => ({ @@ -108,6 +108,43 @@ describe('applyPromptOverrides', () => { expect(result.tools[1].description).toBe('Default description for tool_b'); }); + test('applies inline system prompt override', () => { + const result = applyPromptOverridesFromString( + 'systemPrompt: "Inline system prompt"', + makeMessages( + { role: Raw.ChatRole.System, content: 'Old system' }, + { role: Raw.ChatRole.User, content: 'Hello' }, + ), + makeTools('tool_a'), + logService, + ); + + expect(result.messages[0]).toEqual({ + role: Raw.ChatRole.System, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Inline system prompt' }], + }); + expect(result.messages[1]).toEqual({ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }], + }); + }); + + test('applies inline tool description overrides', () => { + const result = applyPromptOverridesFromString( + [ + 'toolDescriptions:', + ' tool_a:', + ' description: "Inline description"', + ].join('\n'), + makeMessages(), + makeTools('tool_a', 'tool_b'), + logService, + ); + + expect(result.tools[0].description).toBe('Inline description'); + expect(result.tools[1].description).toBe('Default description for tool_b'); + }); + test('applies both system prompt and tool description overrides', async () => { const fileUri = URI.file('/override.yaml'); fileSystemService.mockFile(fileUri, [ @@ -149,6 +186,18 @@ describe('applyPromptOverrides', () => { expect(result.tools).toEqual(tools); }); + test('returns unchanged and logs warning on invalid inline YAML', () => { + const warnSpy = vi.spyOn(logService, 'warn'); + const messages = makeMessages({ role: Raw.ChatRole.System, content: 'original' }); + const tools = makeTools('tool_a'); + + const result = applyPromptOverridesFromString('{{{{not valid yaml', messages, tools, logService); + + expect(result.messages).toEqual(messages); + expect(result.tools).toEqual(tools); + expect(warnSpy).toHaveBeenCalledOnce(); + }); + test('silently ignores tool names not found in available tools', async () => { const fileUri = URI.file('/override.yaml'); fileSystemService.mockFile(fileUri, [ @@ -202,4 +251,28 @@ describe('applyPromptOverrides', () => { await applyPromptOverrides(fileUri, messages, tools, fileSystemService, logService); expect(warnSpy).toHaveBeenCalledTimes(2); }); + + test('prefers inline prompt override text over prompt override file', async () => { + const fileUri = URI.file('/override.yaml'); + fileSystemService.mockFile(fileUri, 'systemPrompt: "From file"'); + const traceSpy = vi.spyOn(logService, 'trace'); + + const result = await applyConfiguredPromptOverrides( + 'systemPrompt: "From inline"', + fileUri.fsPath, + makeMessages( + { role: Raw.ChatRole.System, content: 'Old system' }, + { role: Raw.ChatRole.User, content: 'Hello' }, + ), + makeTools('tool_a'), + fileSystemService, + logService, + ); + + expect(result.messages[0]).toEqual({ + role: Raw.ChatRole.System, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'From inline' }], + }); + expect(traceSpy).toHaveBeenCalledWith('[PromptOverride] Both inline prompt override text and prompt override file are configured; using inline prompt override text'); + }); }); diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index e1e2043c4daeb..1aafc74f0d6a2 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -54,7 +54,7 @@ import { ToolName } from '../../tools/common/toolNames'; import { IToolsService, ToolCallCancelledError } from '../../tools/common/toolsService'; import { ReadFileParams } from '../../tools/node/readFileTool'; import { isHookAbortError, processHookResults } from './hookResultProcessor'; -import { applyPromptOverrides } from './promptOverride'; +import { applyConfiguredPromptOverrides } from './promptOverride'; export const enum ToolCallLimitBehavior { Confirm, @@ -1300,12 +1300,14 @@ export abstract class ToolCallingLoop { }); }); + it('prompt override string setting uses camelCase', () => { + const advancedSection = packageJson.contributes.configuration.find(section => section.id === 'advanced')!; + const promptOverrideStringKey = ConfigKey.Advanced.DebugPromptOverrideString; + + expect(promptOverrideStringKey.fullyQualifiedId).toBe('github.copilot.chat.debug.promptOverrideString'); + expect(promptOverrideStringKey.fullyQualifiedOldId).toBeUndefined(); + expect(Object.keys(advancedSection.properties)).toContain(promptOverrideStringKey.fullyQualifiedId); + }); + it('all localization strings in package.json are present in package.nls.json', async () => { // Get all keys from package.nls.json const packageJsonPath = path.join(__dirname, '../../../../package.json'); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 07733065b1bfb..9e5b20f9bb222 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -591,6 +591,7 @@ export namespace ConfigKey { * Note: this should not be used while self-hosting because it might lead to * a fundamental different experience compared to our end-users. */ + export const DebugPromptOverrideString = defineSetting('chat.debug.promptOverrideString', ConfigType.Simple, null); export const DebugPromptOverrideFile = defineSetting('chat.debug.promptOverrideFile', ConfigType.Simple, null); export const WorkspacePrototypeAdoCodeSearchEndpointOverride = defineAndMigrateSetting('chat.advanced.workspace.prototypeAdoCodeSearchEndpointOverride', 'chat.workspace.prototypeAdoCodeSearchEndpointOverride', ''); export const FeedbackOnChange = defineAndMigrateSetting('chat.advanced.feedback.onChange', 'chat.feedback.onChange', false); @@ -830,6 +831,14 @@ export namespace ConfigKey { export const InstantApplyModelName = defineTeamInternalSetting('chat.advanced.instantApply.modelName', ConfigType.ExperimentBased, CHAT_MODEL.GPT4OPROXY); export const VerifyTextDocumentChanges = defineTeamInternalSetting('chat.advanced.inlineEdits.verifyTextDocumentChanges', ConfigType.ExperimentBased, false); export const UseAutoModeRouting = defineTeamInternalSetting('chat.advanced.useAutoModeRouter', ConfigType.ExperimentBased, false); + /** Controls which `routing_method` value is sent to the auto-intent-service per request + * when `UseAutoModeRouting` is enabled. + * '' (empty/default) = omit `routing_method` and use the server default. + * 'binary' = binary classifier v1. + * 'hydra' = HYDRA multi-head capability matching. + * For experiments, this setting selects the routing method only when router usage is enabled; + * it does not by itself determine whether the router is called. */ + export const AutoModeRoutingMethod = defineTeamInternalSetting('chat.advanced.autoModeRoutingMethod', ConfigType.ExperimentBased, '', undefined, undefined, { experimentName: 'copilotchat.autoModeRoutingMethod' }); /** Inline Completions */ export const InlineCompletionsDefaultDiagnosticsOptions = defineTeamInternalSetting('chat.advanced.inlineCompletions.defaultDiagnosticsOptionsString', ConfigType.ExperimentBased, undefined); @@ -998,6 +1007,8 @@ export namespace ConfigKey { /** Model override for Ask agent (empty = use default) */ export const AskAgentModel = defineSetting('chat.askAgent.model', ConfigType.Simple, ''); + /** Whether the Explore (Code Research) subagent is enabled */ + export const ExploreAgentEnabled = defineSetting('chat.exploreAgent.enabled', ConfigType.ExperimentBased, true); /** Model override for Explore (Code Research) agent — reads from core `chat.exploreAgent.defaultModel` */ export const ExploreAgentModel = defineSetting('chat.exploreAgent.model', ConfigType.Simple, ''); diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 1341b8d932aa9..608b8a54683c0 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -277,7 +277,13 @@ export class AutomodeService extends Disposable implements IAutomodeService { previous_model: entry?.endpoint?.model, turn_number: (entry?.turnCount ?? 0) + 1, }; - const result = await this._routerDecisionFetcher.getRouterDecision(prompt, token.session_token, token.available_models, undefined, contextSignals, chatRequest?.sessionId, chatRequest?.id); + const routingMethod = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AutoModeRoutingMethod, this._expService) || undefined; + const result = await this._routerDecisionFetcher.getRouterDecision(prompt, token.session_token, token.available_models, undefined, contextSignals, chatRequest?.sessionId, chatRequest?.id, routingMethod); + + if (result.fallback) { + this._logService.info(`[AutomodeService] Router signaled fallback: ${result.fallback_reason ?? 'unknown'}, routing_method=${result.routing_method ?? 'n/a'}`); + return { lastRoutedPrompt: prompt, fallbackReason: 'routerFallback' }; + } if (!result.candidate_models.length) { return { lastRoutedPrompt: prompt, fallbackReason: 'emptyCandidateList' }; diff --git a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts index 948d683661bb1..9a9b45253a721 100644 --- a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts @@ -13,7 +13,7 @@ import { ITelemetryService } from '../../telemetry/common/telemetry'; import { ICAPIClientService } from '../common/capiClient'; export interface RouterDecisionResponse { - predicted_label: 'needs_reasoning' | 'no_reasoning'; + predicted_label: 'needs_reasoning' | 'no_reasoning' | 'fallback'; confidence: number; latency_ms: number; candidate_models: string[]; @@ -22,6 +22,12 @@ export interface RouterDecisionResponse { no_reasoning: number; }; sticky_override?: boolean; + routing_method?: string; + fallback?: boolean; + fallback_reason?: string; + hydra_scores?: Record; + chosen_model?: string; + chosen_shortfall?: number; } export interface RoutingContextSignals { @@ -48,12 +54,15 @@ export class RouterDecisionFetcher { ) { } - async getRouterDecision(query: string, autoModeToken: string, availableModels: string[], stickyThreshold?: number, contextSignals?: RoutingContextSignals, conversationId?: string, vscodeRequestId?: string): Promise { + async getRouterDecision(query: string, autoModeToken: string, availableModels: string[], stickyThreshold?: number, contextSignals?: RoutingContextSignals, conversationId?: string, vscodeRequestId?: string, routingMethod?: string): Promise { const startTime = Date.now(); const requestBody: Record = { prompt: query, available_models: availableModels, ...contextSignals }; if (stickyThreshold !== undefined) { requestBody.sticky_threshold = stickyThreshold; } + if (routingMethod) { + requestBody.routing_method = routingMethod; + } const copilotToken = (await this._authService.getCopilotToken()).token; const abortController = new AbortController(); const timeout = setTimeout(() => abortController.abort(), 1000); @@ -79,7 +88,7 @@ export class RouterDecisionFetcher { const text = await response.text(); const result: RouterDecisionResponse = JSON.parse(text); const e2eLatencyMs = Date.now() - startTime; - this._logService.trace(`[RouterDecisionFetcher] Prediction: ${result.predicted_label}, (confidence: ${(result.confidence * 100).toFixed(1)}%, scores: needs_reasoning=${(result.scores.needs_reasoning * 100).toFixed(1)}%, no_reasoning=${(result.scores.no_reasoning * 100).toFixed(1)}%) (latency_ms: ${result.latency_ms}, e2e_latency_ms: ${e2eLatencyMs}, candidate models: ${result.candidate_models.join(', ')}, sticky_override: ${result.sticky_override ?? false})`); + this._logService.trace(`[RouterDecisionFetcher] Prediction: ${result.predicted_label}, (confidence: ${(result.confidence * 100).toFixed(1)}%, scores: needs_reasoning=${(result.scores.needs_reasoning * 100).toFixed(1)}%, no_reasoning=${(result.scores.no_reasoning * 100).toFixed(1)}%) (latency_ms: ${result.latency_ms}, e2e_latency_ms: ${e2eLatencyMs}, candidate models: ${result.candidate_models.join(', ')}, sticky_override: ${result.sticky_override ?? false}, routing_method: ${result.routing_method ?? 'n/a'}, fallback: ${result.fallback ?? false})`); this._requestLogger.addEntry({ type: LoggedRequestKind.MarkdownContentRequest, @@ -111,7 +120,10 @@ export class RouterDecisionFetcher { "comment": "Reports the routing decision made by the auto mode router API", "conversationId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The conversation ID in which the routing decision was made." }, "vscodeRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The VS Code chat request id in which the routing decision was made." }, - "predictedLabel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The predicted classification label (needs_reasoning or no_reasoning)" }, + "predictedLabel": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The predicted classification label (needs_reasoning, no_reasoning, or fallback)" }, + "routingMethod": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The routing method used for this request (empty=server default, binary, hydra). Identifies the A/B/C experiment path." }, + "fallback": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the router signaled a fallback to default automod selection." }, + "fallbackReason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The reason provided by the server when fallback is true." }, "confidence": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The confidence score of the routing decision" }, "latencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The latency of the router API call in milliseconds" }, "e2eLatencyMs": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true, "comment": "The end-to-end latency of the router request in milliseconds, including network overhead" } @@ -122,6 +134,9 @@ export class RouterDecisionFetcher { conversationId: conversationId ?? '', vscodeRequestId: vscodeRequestId ?? '', predictedLabel: result.predicted_label, + routingMethod: result.routing_method ?? '', + fallback: String(result.fallback ?? false), + fallbackReason: result.fallback_reason ?? '', }, { confidence: result.confidence, diff --git a/package-lock.json b/package-lock.json index 70d1ae1551faf..04a8a6c71e753 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-oss-dev", - "version": "1.115.0", + "version": "1.116.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.115.0", + "version": "1.116.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -15,6 +15,11 @@ "@github/copilot-sdk": "^0.2.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", + "@microsoft/dev-tunnels-connections": "^1.3.41", + "@microsoft/dev-tunnels-contracts": "^1.3.41", + "@microsoft/dev-tunnels-management": "^1.3.41", + "@microsoft/dev-tunnels-ssh": "^3.12.22", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-1", @@ -1611,6 +1616,118 @@ "resolved": "https://registry.npmjs.org/@microsoft/applicationinsights-shims/-/applicationinsights-shims-2.0.2.tgz", "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, + "node_modules/@microsoft/dev-tunnels-connections": { + "version": "1.3.41", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.41.tgz", + "integrity": "sha512-6TcFQ0BE+lFYRFHJcAEkxyiQ7Y4rXH6jjGGYSjPNEkyiyC6r503m5gukHZGHJE9GOpbH3eVrSZYJ+7/gNULvzA==", + "license": "MIT", + "dependencies": { + "@microsoft/dev-tunnels-contracts": "1.3.41", + "@microsoft/dev-tunnels-management": "1.3.41", + "await-semaphore": "^0.1.3", + "buffer": "^5.2.1", + "debug": "^4.1.1", + "es5-ext": "0.10.64", + "uuid": "^3.3.3", + "vscode-jsonrpc": "^4.0.0", + "websocket": "^1.0.28" + }, + "peerDependencies": { + "@microsoft/dev-tunnels-ssh": "^3.12.22", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22" + } + }, + "node_modules/@microsoft/dev-tunnels-connections/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/@microsoft/dev-tunnels-connections/node_modules/vscode-jsonrpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", + "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-contracts": { + "version": "1.3.41", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.41.tgz", + "integrity": "sha512-TpaIbXVLMS2kX6XmtLpGisy6om4lzI3c6uRsJDV2PrCeCy/unk2H4+cC9Yb2y50iQRTpAiviYoEiT594BOyyYA==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "debug": "^4.1.1", + "vscode-jsonrpc": "^4.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-contracts/node_modules/vscode-jsonrpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", + "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-management": { + "version": "1.3.41", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.41.tgz", + "integrity": "sha512-Xaj9l4ccUOLVcO4MBAC0L1bz8dHKXMqZ471jgKKQvX0n3cTvLEGVab3rS5qYMraNxUTSUTeDiGCh3ZsKZ23RsQ==", + "license": "MIT", + "dependencies": { + "@microsoft/dev-tunnels-contracts": "1.3.41", + "axios": "^1.8.4", + "buffer": "^5.2.1", + "debug": "^4.1.1", + "vscode-jsonrpc": "^4.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-management/node_modules/vscode-jsonrpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", + "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-ssh": { + "version": "3.12.22", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh/-/dev-tunnels-ssh-3.12.22.tgz", + "integrity": "sha512-BiYjRiBAbvo306zeZmRaLgY5f3JuN8x0F108ZcssVwL11CUP5zvYwjyOUysMOSH42qfQG/zCG+1b56muLGW+5A==", + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "debug": "^4.1.1", + "diffie-hellman": "^5.0.3", + "vscode-jsonrpc": "^4.0.0" + } + }, + "node_modules/@microsoft/dev-tunnels-ssh-tcp": { + "version": "3.12.22", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-ssh-tcp/-/dev-tunnels-ssh-tcp-3.12.22.tgz", + "integrity": "sha512-oZs7CLRv5V6Lo0+oFGsW2EwKFAzAdQ+sa+06yc9O4CyV0HAywP/E6o52vhjVJD4RpsZzCTsUf0ApoYYr05hDpw==", + "license": "MIT", + "dependencies": { + "@microsoft/dev-tunnels-ssh": "~3.12" + } + }, + "node_modules/@microsoft/dev-tunnels-ssh/node_modules/vscode-jsonrpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", + "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, "node_modules/@microsoft/dynamicproto-js": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.9.tgz", @@ -5231,8 +5348,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k= sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/atob": { "version": "2.1.2", @@ -5258,6 +5374,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-semaphore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", + "integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -5504,6 +5646,12 @@ "dev": true, "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -5565,6 +5713,12 @@ "node": ">=8" } }, + "node_modules/brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", + "license": "MIT" + }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -5660,6 +5814,19 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -5792,7 +5959,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6329,7 +6495,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -6341,7 +6506,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk= sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -6917,7 +7081,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/d/-/d-1.0.1.tgz", "integrity": "sha512-m62ShEObQ39CfralilEQRjH6oAMtNCV1xJyEx5LpRYUVN+EviphDgUc/F3hnYbADmkiNs67Y+3ylmlG7Lnu+FA==", - "dev": true, "dependencies": { "es5-ext": "^0.10.50", "type": "^1.0.1" @@ -7304,6 +7467,17 @@ "node": ">=0.3.1" } }, + "node_modules/diffie-hellman": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7364,7 +7538,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -7692,7 +7865,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7702,7 +7874,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -7717,7 +7888,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -7730,7 +7900,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7743,11 +7912,11 @@ } }, "node_modules/es5-ext": { - "version": "0.10.63", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.63.tgz", - "integrity": "sha512-hUCZd2Byj/mNKjfP9jXrdVZ62B8KuA/VoK7X8nUh5qT+AxDmcbvZz041oDVZdbIN1qW6XY9VDNwzkvKnZvK2TQ==", - "dev": true, + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", "hasInstallScript": true, + "license": "ISC", "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", @@ -7769,7 +7938,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c= sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "dev": true, "dependencies": { "d": "1", "es5-ext": "^0.10.35", @@ -7780,7 +7948,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.3.tgz", "integrity": "sha512-NJ6Yn3FuDinBaBRWl/q5X/s4koRHBrgKAu+yGI6JCBeiu3qrcbJhwT2GeR/EXVfylRk8dpQVJoLEFhK+Mu31NA==", - "dev": true, "dependencies": { "d": "^1.0.1", "ext": "^1.1.2" @@ -8021,7 +8188,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "dev": true, "dependencies": { "d": "^1.0.1", "es5-ext": "^0.10.62", @@ -8035,8 +8201,7 @@ "node_modules/esniff/node_modules/type": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", - "dev": true + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" }, "node_modules/espree": { "version": "10.4.0", @@ -8139,7 +8304,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk= sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "dev": true, "dependencies": { "d": "1", "es5-ext": "~0.10.14" @@ -8491,7 +8655,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/ext/-/ext-1.4.0.tgz", "integrity": "sha512-Key5NIsUxdqKg3vIsdw9dSuXpPCQ297y6wBjL30edxwPgt2E44WcWBZey/ZvUc6sERLTxKdyCu4gZFmUbk1Q7A==", - "dev": true, "dependencies": { "type": "^2.0.0" } @@ -8499,8 +8662,7 @@ "node_modules/ext/node_modules/type": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type/-/type-2.1.0.tgz", - "integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==", - "dev": true + "integrity": "sha512-G9absDWvhAWCV2gmF1zKud3OyC61nZDwWvBL2DApaVFogI07CprggiQAOOjvp2NRjYWFzPyu7vwtDrQFq8jeSA==" }, "node_modules/extend": { "version": "3.0.2", @@ -9100,6 +9262,26 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -9147,10 +9329,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "dev": true, + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -9291,7 +9472,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -9353,7 +9533,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -9391,7 +9570,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -9970,7 +10148,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11357,7 +11534,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11370,7 +11546,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -11449,7 +11624,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -12262,6 +12436,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/is-unc-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", @@ -13499,7 +13679,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13611,6 +13790,19 @@ "node": ">=8.6" } }, + "node_modules/miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + }, + "bin": { + "miller-rabin": "bin/miller-rabin" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -13627,7 +13819,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -13637,7 +13828,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -14135,8 +14325,7 @@ "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "dev": true + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==" }, "node_modules/nise": { "version": "5.1.0", @@ -14206,6 +14395,17 @@ } } }, + "node_modules/node-gyp-build": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", + "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-html-markdown": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/node-html-markdown/-/node-html-markdown-1.3.0.tgz", @@ -15895,6 +16095,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -18720,8 +18929,7 @@ "node_modules/type": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/type/-/type-1.2.0.tgz", - "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==", - "dev": true + "integrity": "sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==" }, "node_modules/type-check": { "version": "0.4.0", @@ -18805,6 +19013,15 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typescript": { "version": "6.0.0-dev.20260306", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.0-dev.20260306.tgz", @@ -19129,6 +19346,19 @@ "node": ">= 0.8.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -19573,6 +19803,38 @@ "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -19844,6 +20106,16 @@ "node": ">=10" } }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", diff --git a/package.json b/package.json index b05241e50898f..a89a241a45922 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-oss-dev", - "version": "1.115.0", + "version": "1.116.0", "distro": "846b868cab05db8a093dad791caa5c61e330f7a9", "author": { "name": "Microsoft Corporation" @@ -92,6 +92,11 @@ "@github/copilot-sdk": "^0.2.0", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", + "@microsoft/dev-tunnels-connections": "^1.3.41", + "@microsoft/dev-tunnels-contracts": "^1.3.41", + "@microsoft/dev-tunnels-management": "^1.3.41", + "@microsoft/dev-tunnels-ssh": "^3.12.22", + "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", "@vscode/codicons": "^0.0.46-1", diff --git a/src/vs/base/browser/ui/scrollbar/media/scrollbars.css b/src/vs/base/browser/ui/scrollbar/media/scrollbars.css index be500bc2e56c4..c24eae8caedc0 100644 --- a/src/vs/base/browser/ui/scrollbar/media/scrollbars.css +++ b/src/vs/base/browser/ui/scrollbar/media/scrollbars.css @@ -27,6 +27,11 @@ transition: opacity 800ms linear; } +.disable-animations .monaco-scrollable-element > .visible, +.disable-animations .monaco-scrollable-element > .invisible.fade { + transition: none; +} + /* Scrollable Content Inset Shadow */ .monaco-scrollable-element > .shadow { position: absolute; diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index e5aaa42487681..d178163e11685 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -1492,6 +1492,17 @@ export let _runWhenIdle: (targetWindow: IdleApi, callback: (idle: IdleDeadline) runWhenGlobalIdle = (runner, timeout) => _runWhenIdle(globalThis, runner, timeout); })(); +export function installFakeRunWhenIdle(fakeImpl: typeof _runWhenIdle): IDisposable { + const origRunWhenIdle = _runWhenIdle; + const origRunWhenGlobalIdle = runWhenGlobalIdle; + _runWhenIdle = fakeImpl; + runWhenGlobalIdle = (runner, timeout) => fakeImpl(globalThis, runner, timeout); + return toDisposable(() => { + _runWhenIdle = origRunWhenIdle; + runWhenGlobalIdle = origRunWhenGlobalIdle; + }); +} + export abstract class AbstractIdleValue { private readonly _executor: () => void; diff --git a/src/vs/base/test/common/timeTravelScheduler.ts b/src/vs/base/test/common/timeTravelScheduler.ts index 72f8d8985cecf..117cf4e60c8dd 100644 --- a/src/vs/base/test/common/timeTravelScheduler.ts +++ b/src/vs/base/test/common/timeTravelScheduler.ts @@ -97,6 +97,7 @@ export class AsyncSchedulerProcessor extends Disposable { public readonly onTaskQueueEmpty = this.queueEmptyEmitter.event; private lastError: Error | undefined; + private _virtualDeadline = Number.MAX_SAFE_INTEGER; constructor(private readonly scheduler: TimeTravelScheduler, options?: { useSetImmediate?: boolean; maxTaskCount?: number }) { super(); @@ -109,40 +110,47 @@ export class AsyncSchedulerProcessor extends Disposable { return; } else { this.isProcessing = true; - this.schedule(); + this._schedule(); } })); } - private schedule() { + private _schedule() { // This allows promises created by a previous task to settle and schedule tasks before the next task is run. // Tasks scheduled in those promises might have to run before the current next task. Promise.resolve().then(() => { if (this.useSetImmediate) { - originalGlobalValues.setImmediate(() => this.process()); + originalGlobalValues.setImmediate(() => this._process()); } else if (setTimeout0IsFaster) { - setTimeout0(() => this.process()); + setTimeout0(() => this._process()); } else { - originalGlobalValues.setTimeout(() => this.process()); + originalGlobalValues.setTimeout(() => this._process()); } }); } - private process() { + private _process() { const executedTask = this.scheduler.runNext(); if (executedTask) { this._history.push(executedTask); if (this.history.length >= this.maxTaskCount && this.scheduler.hasScheduledTasks) { const lastTasks = this._history.slice(Math.max(0, this.history.length - 10)).map(h => `${h.source.toString()}: ${h.source.stackTrace}`); - const e = new Error(`Queue did not get empty after processing ${this.history.length} items. These are the last ${lastTasks.length} scheduled tasks:\n${lastTasks.join('\n\n\n')}`); - this.lastError = e; - throw e; + this.lastError = new Error(`Queue did not get empty after processing ${this.history.length} items. These are the last ${lastTasks.length} scheduled tasks:\n${lastTasks.join('\n\n\n')}`); + this.isProcessing = false; + this.queueEmptyEmitter.fire(); + return; + } + + if (this.scheduler.now >= this._virtualDeadline && this.scheduler.hasScheduledTasks) { + this.isProcessing = false; + this.queueEmptyEmitter.fire(); + return; } } if (this.scheduler.hasScheduledTasks) { - this.schedule(); + this._schedule(); } else { this.isProcessing = false; this.queueEmptyEmitter.fire(); @@ -160,11 +168,20 @@ export class AsyncSchedulerProcessor extends Disposable { } else { return Event.toPromise(this.onTaskQueueEmpty).then(() => { if (this.lastError) { - throw this.lastError; + const error = this.lastError; + this.lastError = undefined; + throw error; } }); } } + + waitFor(virtualTimeMs: number): Promise { + this._virtualDeadline = this.scheduler.now + virtualTimeMs; + return this.waitForEmptyQueue().finally(() => { + this._virtualDeadline = Number.MAX_SAFE_INTEGER; + }); + } } diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index ff213c40f16dd..76de7a8e936e7 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -89,6 +89,8 @@ import { IExtensionsScannerService } from '../../../platform/extensionManagement import { ExtensionsScannerService } from '../../../platform/extensionManagement/node/extensionsScannerService.js'; import { ISSHRemoteAgentHostMainService, SSH_REMOTE_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/sshRemoteAgentHost.js'; import { SSHRemoteAgentHostMainService } from '../../../platform/agentHost/node/sshRemoteAgentHostService.js'; +import { ITunnelAgentHostMainService, TUNNEL_AGENT_HOST_CHANNEL } from '../../../platform/agentHost/common/tunnelAgentHost.js'; +import { TunnelAgentHostMainService } from '../../../platform/agentHost/node/tunnelAgentHostService.js'; import { IUserDataProfilesService } from '../../../platform/userDataProfile/common/userDataProfile.js'; import { IExtensionsProfileScannerService } from '../../../platform/extensionManagement/common/extensionsProfileScannerService.js'; import { PolicyChannelClient } from '../../../platform/policy/common/policyIpc.js'; @@ -412,6 +414,9 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // SSH Remote Agent Host services.set(ISSHRemoteAgentHostMainService, new SyncDescriptor(SSHRemoteAgentHostMainService, undefined, true)); + // Tunnel Agent Host + services.set(ITunnelAgentHostMainService, new SyncDescriptor(TunnelAgentHostMainService, undefined, true)); + return new InstantiationService(services); } @@ -490,6 +495,10 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { // SSH Remote Agent Host const sshRemoteAgentHostChannel = ProxyChannel.fromService(accessor.get(ISSHRemoteAgentHostMainService), this._store); this.server.registerChannel(SSH_REMOTE_AGENT_HOST_CHANNEL, sshRemoteAgentHostChannel); + + // Tunnel Agent Host + const tunnelAgentHostChannel = ProxyChannel.fromService(accessor.get(ITunnelAgentHostMainService), this._store); + this.server.registerChannel(TUNNEL_AGENT_HOST_CHANNEL, tunnelAgentHostChannel); } private registerErrorHandler(logService: ILogService): void { diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 8d2b31a2a1775..bdeca0c59528c 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -8,6 +8,7 @@ import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oa import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; +import { IProtectedResourceMetadata } from './state/protocol/state.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; @@ -39,6 +40,8 @@ export interface IAgentSessionMetadata { readonly modifiedTime: number; readonly summary?: string; readonly workingDirectory?: URI; + readonly isRead?: boolean; + readonly isDone?: boolean; } export type AgentProvider = string; @@ -48,32 +51,10 @@ export interface IAgentDescriptor { readonly provider: AgentProvider; readonly displayName: string; readonly description: string; - /** - * Whether the renderer should push a GitHub auth token for this agent. - * @deprecated Use {@link IResourceMetadata.resources} from {@link IAgentService.getResourceMetadata} instead. - */ - readonly requiresAuth: boolean; } // ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- -/** - * Describes the agent host as an OAuth 2.0 protected resource. - * Uses {@link IAuthorizationProtectedResourceMetadata} from RFC 9728 - * to describe auth requirements, enabling clients to resolve tokens - * using the standard VS Code authentication service. - * - * Returned from the server via {@link IAgentService.getResourceMetadata}. - */ -export interface IResourceMetadata { - /** - * Protected resources the agent host requires authentication for. - * Each entry uses the standard RFC 9728 shape so clients can resolve - * tokens via {@link IAuthenticationService.getOrActivateProviderIdForServer}. - */ - readonly resources: readonly IAuthorizationProtectedResourceMetadata[]; -} - /** * Parameters for the `authenticate` command. * Analogous to sending `Authorization: Bearer ` (RFC 6750 section 2.1). @@ -359,7 +340,7 @@ export interface IAgent { listSessions(): Promise; /** Declare protected resources this agent requires auth for (RFC 9728). */ - getProtectedResources(): IAuthorizationProtectedResourceMetadata[]; + getProtectedResources(): IProtectedResourceMetadata[]; /** * Authenticate for a specific resource. Returns true if accepted. @@ -421,28 +402,14 @@ export const IAgentService = createDecorator('agentService'); export interface IAgentService { readonly _serviceBrand: undefined; - /** Discover available agent backends from the agent host. */ - listAgents(): Promise; - - /** - * Retrieve the resource metadata describing auth requirements. - * Modeled on RFC 9728 (OAuth 2.0 Protected Resource Metadata). - */ - getResourceMetadata(): Promise; - /** * Authenticate for a protected resource on the server. * The {@link IAuthenticateParams.resource} must match a resource from - * {@link getResourceMetadata}. Analogous to RFC 6750 bearer token delivery. + * the agent's protectedResources in root state. Analogous to RFC 6750 + * bearer token delivery. */ authenticate(params: IAuthenticateParams): Promise; - /** - * Refresh the model list from all providers, publishing updated - * agents (with models) to root state via `root/agentsChanged`. - */ - refreshModels(): Promise; - /** List all available sessions from the Copilot CLI. */ listSessions(): Promise; diff --git a/src/vs/platform/agentHost/common/remoteAgentHostService.ts b/src/vs/platform/agentHost/common/remoteAgentHostService.ts index 143b0b9a3ab27..bb6da8454d527 100644 --- a/src/vs/platform/agentHost/common/remoteAgentHostService.ts +++ b/src/vs/platform/agentHost/common/remoteAgentHostService.ts @@ -7,6 +7,7 @@ import { Event } from '../../../base/common/event.js'; import { connectionTokenQueryName } from '../../../base/common/network.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { IAgentConnection } from './agentService.js'; +import { TUNNEL_ADDRESS_PREFIX } from './tunnelAgentHost.js'; /** Connection status for a remote agent host. */ export const enum RemoteAgentHostConnectionStatus { @@ -21,13 +22,51 @@ export const RemoteAgentHostsSettingId = 'chat.remoteAgentHosts'; /** Configuration key to enable remote agent host connections. */ export const RemoteAgentHostsEnabledSettingId = 'chat.remoteAgentHostsEnabled'; +export const enum RemoteAgentHostEntryType { + WebSocket = 'websocket', + SSH = 'ssh', + Tunnel = 'tunnel', +} + +export interface IRemoteAgentHostWebSocketConnection { + readonly type: RemoteAgentHostEntryType.WebSocket; + readonly address: string; +} + +export interface IRemoteAgentHostSSHConnection { + readonly type: RemoteAgentHostEntryType.SSH; + readonly address: string; + /** SSH config host alias — if set, the tunnel is re-established on startup. */ + readonly sshConfigHost?: string; +} + +export interface IRemoteAgentHostTunnelConnection { + readonly type: RemoteAgentHostEntryType.Tunnel; + /** Dev tunnel ID. */ + readonly tunnelId: string; + /** Dev tunnel cluster region. */ + readonly clusterId: string; + /** Auth provider used to connect to this tunnel. */ + readonly authProvider?: 'github' | 'microsoft'; +} + +export type RemoteAgentHostConnection = IRemoteAgentHostWebSocketConnection | IRemoteAgentHostSSHConnection | IRemoteAgentHostTunnelConnection; + /** An entry in the {@link RemoteAgentHostsSettingId} setting. */ export interface IRemoteAgentHostEntry { - readonly address: string; readonly name: string; readonly connectionToken?: string; - /** SSH config host alias — if set, the tunnel is re-established on startup. */ - readonly sshConfigHost?: string; + readonly connection: RemoteAgentHostConnection; +} + +export function getEntryAddress(entry: IRemoteAgentHostEntry): string { + switch (entry.connection.type) { + case RemoteAgentHostEntryType.WebSocket: + case RemoteAgentHostEntryType.SSH: + return entry.connection.address; + case RemoteAgentHostEntryType.Tunnel: + return `${TUNNEL_ADDRESS_PREFIX}${entry.connection.tunnelId}`; + } } export const enum RemoteAgentHostInputValidationError { @@ -93,8 +132,8 @@ export interface IRemoteAgentHostService { reconnect(address: string): void; /** - * Register a pre-connected SSH agent connection. - * Used by the SSH service to inject relay-backed connections + * Register a pre-connected agent connection. + * Used by the SSH and tunnel services to inject relay-backed connections * without going through the WebSocket connect flow. */ addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise; @@ -195,3 +234,53 @@ function formatRemoteAgentHostAddress(url: URL, protocol: 'ws:' | 'wss:' | undef const base = protocol ? `${protocol}//${url.host}` : url.host; return `${base}${path}${query}`; } + +/** Raw shape of entries persisted in the {@link RemoteAgentHostsSettingId} setting. */ +export interface IRawRemoteAgentHostEntry { + readonly address: string; + readonly name: string; + readonly connectionToken?: string; + readonly sshConfigHost?: string; +} + +export function rawEntryToEntry(raw: IRawRemoteAgentHostEntry): IRemoteAgentHostEntry | undefined { + if (raw.sshConfigHost) { + return { + name: raw.name, + connectionToken: raw.connectionToken, + connection: { + type: RemoteAgentHostEntryType.SSH, + address: raw.address, + sshConfigHost: raw.sshConfigHost, + }, + }; + } + return { + name: raw.name, + connectionToken: raw.connectionToken, + connection: { + type: RemoteAgentHostEntryType.WebSocket, + address: raw.address, + }, + }; +} + +export function entryToRawEntry(entry: IRemoteAgentHostEntry): IRawRemoteAgentHostEntry | undefined { + switch (entry.connection.type) { + case RemoteAgentHostEntryType.SSH: + return { + address: entry.connection.address, + name: entry.name, + connectionToken: entry.connectionToken, + sshConfigHost: entry.connection.sshConfigHost, + }; + case RemoteAgentHostEntryType.WebSocket: + return { + address: entry.connection.address, + name: entry.name, + connectionToken: entry.connectionToken, + }; + case RemoteAgentHostEntryType.Tunnel: + return undefined; + } +} diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index b2f37b431b132..cfadf736bbdc4 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -b13578c +27a63cf 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 ab09368f19f11..334d66074eb37 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,15 +9,16 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction } from './actions.js'; +import { ActionType, type IStateAction, type IRootAgentsChangedAction, type IRootActiveSessionsChangedAction, type IRootTerminalsChangedAction, type ISessionReadyAction, type ISessionCreationFailedAction, type ISessionTurnStartedAction, type ISessionDeltaAction, type ISessionResponsePartAction, type ISessionToolCallStartAction, type ISessionToolCallDeltaAction, type ISessionToolCallReadyAction, type ISessionToolCallConfirmedAction, type ISessionToolCallCompleteAction, type ISessionToolCallResultConfirmedAction, type ISessionToolCallContentChangedAction, type ISessionTurnCompleteAction, type ISessionTurnCancelledAction, type ISessionErrorAction, type ISessionTitleChangedAction, type ISessionUsageAction, type ISessionReasoningAction, type ISessionModelChangedAction, type ISessionServerToolsChangedAction, type ISessionActiveClientChangedAction, type ISessionActiveClientToolsChangedAction, type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, type ISessionCustomizationsChangedAction, type ISessionCustomizationToggledAction, type ISessionTruncatedAction, type ISessionIsReadChangedAction, type ISessionIsDoneChangedAction, type ITerminalDataAction, type ITerminalInputAction, type ITerminalResizedAction, type ITerminalClaimedAction, type ITerminalTitleChangedAction, type ITerminalCwdChangedAction, type ITerminalExitedAction, type ITerminalClearedAction } from './actions.js'; -// ─── Root vs Session Action Unions ─────────────────────────────────────────── +// ─── Root vs Session vs Terminal Action Unions ─────────────────────────────── /** Union of all root-scoped actions. */ export type IRootAction = | IRootAgentsChangedAction | IRootActiveSessionsChangedAction + | IRootTerminalsChangedAction ; /** Union of all session-scoped actions. */ @@ -33,6 +34,7 @@ export type ISessionAction = | ISessionToolCallConfirmedAction | ISessionToolCallCompleteAction | ISessionToolCallResultConfirmedAction + | ISessionToolCallContentChangedAction | ISessionTurnCompleteAction | ISessionTurnCancelledAction | ISessionErrorAction @@ -49,6 +51,8 @@ export type ISessionAction = | ISessionCustomizationsChangedAction | ISessionCustomizationToggledAction | ISessionTruncatedAction + | ISessionIsReadChangedAction + | ISessionIsDoneChangedAction ; /** Union of session actions that clients may dispatch. */ @@ -67,6 +71,8 @@ export type IClientSessionAction = | ISessionQueuedMessagesReorderedAction | ISessionCustomizationToggledAction | ISessionTruncatedAction + | ISessionIsReadChangedAction + | ISessionIsDoneChangedAction ; /** Union of session actions that only the server may produce. */ @@ -78,6 +84,7 @@ export type IServerSessionAction = | ISessionToolCallStartAction | ISessionToolCallDeltaAction | ISessionToolCallReadyAction + | ISessionToolCallContentChangedAction | ISessionTurnCompleteAction | ISessionErrorAction | ISessionUsageAction @@ -86,6 +93,34 @@ export type IServerSessionAction = | ISessionCustomizationsChangedAction ; +/** Union of all terminal-scoped actions. */ +export type ITerminalAction = + | ITerminalDataAction + | ITerminalInputAction + | ITerminalResizedAction + | ITerminalClaimedAction + | ITerminalTitleChangedAction + | ITerminalCwdChangedAction + | ITerminalExitedAction + | ITerminalClearedAction + ; + +/** Union of terminal actions that clients may dispatch. */ +export type IClientTerminalAction = + | ITerminalInputAction + | ITerminalResizedAction + | ITerminalClaimedAction + | ITerminalTitleChangedAction + | ITerminalClearedAction + ; + +/** Union of terminal actions that only the server may produce. */ +export type IServerTerminalAction = + | ITerminalDataAction + | ITerminalCwdChangedAction + | ITerminalExitedAction + ; + // ─── Client-Dispatchable Map ───────────────────────────────────────────────── /** @@ -95,6 +130,7 @@ export type IServerSessionAction = export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boolean } = { [ActionType.RootAgentsChanged]: false, [ActionType.RootActiveSessionsChanged]: false, + [ActionType.RootTerminalsChanged]: false, [ActionType.SessionReady]: false, [ActionType.SessionCreationFailed]: false, [ActionType.SessionTurnStarted]: true, @@ -106,6 +142,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo [ActionType.SessionToolCallConfirmed]: true, [ActionType.SessionToolCallComplete]: true, [ActionType.SessionToolCallResultConfirmed]: true, + [ActionType.SessionToolCallContentChanged]: false, [ActionType.SessionTurnComplete]: false, [ActionType.SessionTurnCancelled]: true, [ActionType.SessionError]: false, @@ -122,4 +159,14 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in IStateAction['type']]: boo [ActionType.SessionCustomizationsChanged]: false, [ActionType.SessionCustomizationToggled]: true, [ActionType.SessionTruncated]: true, + [ActionType.SessionIsReadChanged]: true, + [ActionType.SessionIsDoneChanged]: true, + [ActionType.TerminalData]: false, + [ActionType.TerminalInput]: true, + [ActionType.TerminalResized]: true, + [ActionType.TerminalClaimed]: true, + [ActionType.TerminalTitleChanged]: true, + [ActionType.TerminalCwdChanged]: false, + [ActionType.TerminalExited]: false, + [ActionType.TerminalCleared]: true, }; diff --git a/src/vs/platform/agentHost/common/state/protocol/actions.ts b/src/vs/platform/agentHost/common/state/protocol/actions.ts index 9a9b6cfb8719a..b53aae3d146b0 100644 --- a/src/vs/platform/agentHost/common/state/protocol/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/actions.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type URI, type StringOrMarkdown, type IAgentInfo, type IErrorInfo, type IUserMessage, type IResponsePart, type IToolCallResult, type IToolResultContent, type IToolDefinition, type ISessionActiveClient, type IUsageInfo, type ISessionCustomization, type ITerminalInfo, type ITerminalClaim } from './state.js'; // ─── Action Type Enum ──────────────────────────────────────────────────────── @@ -30,6 +30,7 @@ export const enum ActionType { SessionToolCallConfirmed = 'session/toolCallConfirmed', SessionToolCallComplete = 'session/toolCallComplete', SessionToolCallResultConfirmed = 'session/toolCallResultConfirmed', + SessionToolCallContentChanged = 'session/toolCallContentChanged', SessionTurnComplete = 'session/turnComplete', SessionTurnCancelled = 'session/turnCancelled', SessionError = 'session/error', @@ -46,6 +47,17 @@ export const enum ActionType { SessionCustomizationsChanged = 'session/customizationsChanged', SessionCustomizationToggled = 'session/customizationToggled', SessionTruncated = 'session/truncated', + SessionIsReadChanged = 'session/isReadChanged', + SessionIsDoneChanged = 'session/isDoneChanged', + RootTerminalsChanged = 'root/terminalsChanged', + TerminalData = 'terminal/data', + TerminalInput = 'terminal/input', + TerminalResized = 'terminal/resized', + TerminalClaimed = 'terminal/claimed', + TerminalTitleChanged = 'terminal/titleChanged', + TerminalCwdChanged = 'terminal/cwdChanged', + TerminalExited = 'terminal/exited', + TerminalCleared = 'terminal/cleared', } // ─── Action Envelope ───────────────────────────────────────────────────────── @@ -118,6 +130,21 @@ export interface IRootActiveSessionsChangedAction { activeSessions: number; } +/** + * Fired when the list of known terminals changes. + * + * Full-replacement semantics: the `terminals` array replaces the previous + * `terminals` entirely. + * + * @category Root Actions + * @version 1 + */ +export interface IRootTerminalsChangedAction { + type: ActionType.RootTerminalsChanged; + /** Updated terminal list (full replacement) */ + terminals: ITerminalInfo[]; +} + // ─── Session Actions ───────────────────────────────────────────────────────── /** @@ -356,6 +383,22 @@ export interface ISessionToolCallResultConfirmedAction extends IToolCallActionBa approved: boolean; } +/** + * Partial content produced while a tool is still executing. + * + * Replaces the `content` array on the running tool call state. Clients can + * use this to display live feedback (e.g. a terminal reference) before the + * tool completes. + * + * @category Session Actions + * @version 1 + */ +export interface ISessionToolCallContentChangedAction extends IToolCallActionBase { + type: ActionType.SessionToolCallContentChanged; + /** The current partial content for the running tool call */ + content: IToolResultContent[]; +} + /** * Turn finished — the assistant is idle. * @@ -469,6 +512,42 @@ export interface ISessionModelChangedAction { model: string; } +/** + * The read state of the session changed. + * + * Dispatched by a client to mark a session as read (e.g. after viewing it) + * or unread (e.g. after new activity since the client last looked at it). + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionIsReadChangedAction { + type: ActionType.SessionIsReadChanged; + /** Session URI */ + session: URI; + /** Whether the session has been read */ + isRead: boolean; +} + +/** + * The done state of the session changed. + * + * Dispatched by a client to mark a session as done (e.g. the task is + * complete) or to reopen it. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface ISessionIsDoneChangedAction { + type: ActionType.SessionIsDoneChanged; + /** Session URI */ + session: URI; + /** Whether the session is done */ + isDone: boolean; +} + /** * Server tools for this session have changed. * @@ -657,6 +736,149 @@ export interface ISessionQueuedMessagesReorderedAction { order: string[]; } +// ─── Terminal Actions ──────────────────────────────────────────────────────── + +/** + * Terminal output data (pty → client direction). + * + * Appends `data` to the terminal's `content` in the reducer. + * + * `terminal/data` and `terminal/input` are intentionally separate actions + * because standard write-ahead reconciliation is not safe for terminal I/O. + * A pty is a stateful, mutable process — optimistically applying input or + * predicting output would produce incorrect state. Instead, `terminal/input` + * is a side-effect-only action (client → server → pty), and `terminal/data` + * is server-authoritative output (pty → server → client). + * + * @category Terminal Actions + * @version 1 + */ +export interface ITerminalDataAction { + type: ActionType.TerminalData; + /** Terminal URI */ + terminal: URI; + /** Output data (may contain ANSI escape sequences) */ + data: string; +} + +/** + * Keyboard input sent to the terminal process (client → pty direction). + * + * This is a side-effect-only action: the server forwards the data to the + * terminal's pty. The reducer treats this as a no-op since `terminal/data` + * actions will reflect any resulting output. + * + * See `terminal/data` for why these two actions are kept separate. + * + * @category Terminal Actions + * @version 1 + * @clientDispatchable + */ +export interface ITerminalInputAction { + type: ActionType.TerminalInput; + /** Terminal URI */ + terminal: URI; + /** Input data to send to the pty */ + data: string; +} + +/** + * Terminal dimensions changed. + * + * Dispatchable by clients to request a resize, or by the server to inform + * clients of the actual terminal dimensions. + * + * @category Terminal Actions + * @version 1 + * @clientDispatchable + */ +export interface ITerminalResizedAction { + type: ActionType.TerminalResized; + /** Terminal URI */ + terminal: URI; + /** Terminal width in columns */ + cols: number; + /** Terminal height in rows */ + rows: number; +} + +/** + * Terminal claim changed. A client or session transfers ownership of the terminal. + * + * The server SHOULD reject if the dispatching client does not currently hold + * the claim. + * + * @category Terminal Actions + * @version 1 + * @clientDispatchable + */ +export interface ITerminalClaimedAction { + type: ActionType.TerminalClaimed; + /** Terminal URI */ + terminal: URI; + /** The new claim */ + claim: ITerminalClaim; +} + +/** + * Terminal title changed. + * + * Fired by the server when the terminal process updates its title (e.g. via + * escape sequences), or dispatched by a client to rename a terminal. + * + * @category Terminal Actions + * @version 1 + * @clientDispatchable + */ +export interface ITerminalTitleChangedAction { + type: ActionType.TerminalTitleChanged; + /** Terminal URI */ + terminal: URI; + /** New terminal title */ + title: string; +} + +/** + * Terminal working directory changed. + * + * @category Terminal Actions + * @version 1 + */ +export interface ITerminalCwdChangedAction { + type: ActionType.TerminalCwdChanged; + /** Terminal URI */ + terminal: URI; + /** New working directory */ + cwd: URI; +} + +/** + * Terminal process exited. + * + * @category Terminal Actions + * @version 1 + */ +export interface ITerminalExitedAction { + type: ActionType.TerminalExited; + /** Terminal URI */ + terminal: URI; + /** Process exit code. `undefined` if the process was killed without an exit code. */ + exitCode?: number; +} + +/** + * Terminal scrollback buffer cleared. + * + * @category Terminal Actions + * @version 1 + * @clientDispatchable + */ +export interface ITerminalClearedAction { + type: ActionType.TerminalCleared; + /** Terminal URI */ + terminal: URI; +} + // ─── Discriminated Union ───────────────────────────────────────────────────── /** @@ -665,6 +887,7 @@ export interface ISessionQueuedMessagesReorderedAction { export type IStateAction = | IRootAgentsChangedAction | IRootActiveSessionsChangedAction + | IRootTerminalsChangedAction | ISessionReadyAction | ISessionCreationFailedAction | ISessionTurnStartedAction @@ -676,6 +899,7 @@ export type IStateAction = | ISessionToolCallConfirmedAction | ISessionToolCallCompleteAction | ISessionToolCallResultConfirmedAction + | ISessionToolCallContentChangedAction | ISessionTurnCompleteAction | ISessionTurnCancelledAction | ISessionErrorAction @@ -691,4 +915,14 @@ export type IStateAction = | ISessionQueuedMessagesReorderedAction | ISessionCustomizationsChangedAction | ISessionCustomizationToggledAction - | ISessionTruncatedAction; + | ISessionTruncatedAction + | ISessionIsReadChangedAction + | ISessionIsDoneChangedAction + | ITerminalDataAction + | ITerminalInputAction + | ITerminalResizedAction + | ITerminalClaimedAction + | ITerminalTitleChangedAction + | ITerminalCwdChangedAction + | ITerminalExitedAction + | ITerminalClearedAction; diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index fd67388ee2124..2364715f22bf5 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { URI, ISnapshot, ISessionSummary, ITurn } from './state.js'; +import type { URI, ISnapshot, ISessionSummary, ITurn, ITerminalClaim } from './state.js'; import type { IActionEnvelope, IStateAction } from './actions.js'; // ─── initialize ────────────────────────────────────────────────────────────── @@ -211,6 +211,55 @@ export interface IDisposeSessionParams { session: URI; } +// ─── createTerminal ────────────────────────────────────────────────────────── + +/** + * Creates a new terminal on the server. + * + * After creation, the client should subscribe to the terminal URI to receive + * state updates. The server dispatches `root/terminalsChanged` to update the + * root terminal list. + * + * @category Commands + * @method createTerminal + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface ICreateTerminalParams { + /** Terminal URI (client-chosen) */ + terminal: URI; + /** Initial owner of the terminal */ + claim: ITerminalClaim; + /** Human-readable terminal name */ + name?: string; + /** Initial working directory URI */ + cwd?: URI; + /** Initial terminal width in columns */ + cols?: number; + /** Initial terminal height in rows */ + rows?: number; +} + +// ─── disposeTerminal ───────────────────────────────────────────────────────── + +/** + * Disposes a terminal and kills its process if still running. + * + * The server dispatches `root/terminalsChanged` to remove the terminal from + * the root terminal list. + * + * @category Commands + * @method disposeTerminal + * @direction Client → Server + * @messageType Request + * @version 1 + */ +export interface IDisposeTerminalParams { + /** Terminal URI to dispose */ + terminal: URI; +} + // ─── listSessions ──────────────────────────────────────────────────────────── /** diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 253893ab6619a..3fe5f64233307 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, ICreateTerminalParams, IDisposeTerminalParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; import type { IActionEnvelope } from './actions.js'; import type { IProtocolNotification } from './notifications.js'; @@ -62,6 +62,8 @@ export interface ICommandMap { 'subscribe': { params: ISubscribeParams; result: ISubscribeResult }; 'createSession': { params: ICreateSessionParams; result: null }; 'disposeSession': { params: IDisposeSessionParams; result: null }; + 'createTerminal': { params: ICreateTerminalParams; result: null }; + 'disposeTerminal': { params: IDisposeTerminalParams; result: null }; 'listSessions': { params: IListSessionsParams; result: IListSessionsResult }; 'resourceRead': { params: IResourceReadParams; result: IResourceReadResult }; 'resourceWrite': { params: IResourceWriteParams; result: IResourceWriteResult }; diff --git a/src/vs/platform/agentHost/common/state/protocol/reducers.ts b/src/vs/platform/agentHost/common/state/protocol/reducers.ts index 128074285b2e7..d93a8062d76af 100644 --- a/src/vs/platform/agentHost/common/state/protocol/reducers.ts +++ b/src/vs/platform/agentHost/common/state/protocol/reducers.ts @@ -7,8 +7,8 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from './actions.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } from './state.js'; -import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction } from './action-origin.generated.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallCancellationReason, ResponsePartKind, PendingMessageKind, type IRootState, type ISessionState, type ITerminalState, type IToolCallState, type IResponsePart, type IToolCallResponsePart, type ITurn, type IPendingMessage } from './state.js'; +import { IS_CLIENT_DISPATCHABLE, type IRootAction, type ISessionAction, type IClientSessionAction, type ITerminalAction, type IClientTerminalAction } from './action-origin.generated.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -184,6 +184,9 @@ export function rootReducer(state: IRootState, action: IRootAction, log?: (msg: case ActionType.RootActiveSessionsChanged: return { ...state, activeSessions: action.activeSessions }; + case ActionType.RootTerminalsChanged: + return { ...state, terminals: action.terminals }; + default: softAssertNever(action, log); return state; @@ -218,7 +221,7 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log case ActionType.SessionTurnStarted: { let next: ISessionState = { ...state, - summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now() }, + summary: { ...state.summary, status: SessionStatus.InProgress, modifiedAt: Date.now(), isRead: false }, activeTurn: { id: action.turnId, userMessage: action.userMessage, @@ -417,6 +420,17 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log }; }); + case ActionType.SessionToolCallContentChanged: + return updateToolCallInParts(state, action.turnId, action.toolCallId, tc => { + if (tc.status !== ToolCallStatus.Running) { + return tc; + } + return { + ...tc, + content: action.content, + }; + }); + // ── Metadata ────────────────────────────────────────────────────────── case ActionType.SessionTitleChanged: @@ -448,6 +462,18 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log summary: { ...state.summary, model: action.model, modifiedAt: Date.now() }, }; + case ActionType.SessionIsReadChanged: + return { + ...state, + summary: { ...state.summary, isRead: action.isRead }, + }; + + case ActionType.SessionIsDoneChanged: + return { + ...state, + summary: { ...state.summary, isDone: action.isDone }, + }; + case ActionType.SessionServerToolsChanged: return { ...state, serverTools: action.tools }; @@ -571,14 +597,53 @@ export function sessionReducer(state: ISessionState, action: ISessionAction, log } } +// ─── Terminal Reducer ──────────────────────────────────────────────────────── + +/** + * Pure reducer for terminal state. Handles all {@link ITerminalAction} variants. + */ +export function terminalReducer(state: ITerminalState, action: ITerminalAction, log?: (msg: string) => void): ITerminalState { + switch (action.type) { + case ActionType.TerminalData: + return { ...state, content: state.content + action.data }; + + case ActionType.TerminalInput: + // Side-effect-only: the server forwards to the pty. + // No state change in the reducer. + return state; + + case ActionType.TerminalResized: + return { ...state, cols: action.cols, rows: action.rows }; + + case ActionType.TerminalClaimed: + return { ...state, claim: action.claim }; + + case ActionType.TerminalTitleChanged: + return { ...state, title: action.title }; + + case ActionType.TerminalCwdChanged: + return { ...state, cwd: action.cwd }; + + case ActionType.TerminalExited: + return { ...state, exitCode: action.exitCode }; + + case ActionType.TerminalCleared: + return { ...state, content: '' }; + + default: + softAssertNever(action, log); + return state; + } +} + // ─── Dispatch Validation ───────────────────────────────────────────────────── /** - * Type guard that checks whether an action may be dispatched by a client. + * Type guard that checks whether a session action may be dispatched by a client. * * Servers SHOULD call this to validate incoming `dispatchAction` requests * and reject any action the client is not allowed to originate. */ -export function isClientDispatchable(action: ISessionAction): action is IClientSessionAction { +export function isClientDispatchable(action: ISessionAction | ITerminalAction): action is IClientSessionAction | IClientTerminalAction { return IS_CLIENT_DISPATCHABLE[action.type]; } diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 49cefab87d7af..78d976a018de1 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -152,6 +152,8 @@ export interface IRootState { agents: IAgentInfo[]; /** Number of active (non-disposed) sessions on the server */ activeSessions?: number; + /** Known terminals on the server. Subscribe to individual terminal URIs for full state. */ + terminals?: ITerminalInfo[]; } /** @@ -313,6 +315,20 @@ export interface ISessionActiveClient { customizations?: ICustomizationRef[]; } +/** + * A summary of changes to a single file within a session. + * + * @category Session State + */ +export interface ISessionFileDiff { + /** URI of the affected file */ + uri: URI; + /** Number of items added (e.g., lines for text files, cells for notebooks) */ + added?: number; + /** Number of items removed (e.g., lines for text files, cells for notebooks) */ + removed?: number; +} + /** * @category Session State */ @@ -333,6 +349,12 @@ export interface ISessionSummary { model?: string; /** The working directory URI for this session */ workingDirectory?: URI; + /** Whether the client has viewed this session since its last modification */ + isRead?: boolean; + /** Whether the session has been marked as done by the client */ + isDone?: boolean; + /** Files changed during this session with diff statistics */ + diffs?: ISessionFileDiff[]; } // ─── Turn Types ────────────────────────────────────────────────────────────── @@ -659,6 +681,13 @@ export interface IToolCallRunningState extends IToolCallBase, IToolCallParameter status: ToolCallStatus.Running; /** How the tool was confirmed for execution */ confirmed: ToolCallConfirmationReason; + /** + * Partial content produced while the tool is still executing. + * + * For example, a terminal content block lets clients subscribe to live + * output before the tool completes. + */ + content?: IToolResultContent[]; } /** @@ -795,6 +824,7 @@ export const enum ToolResultContentType { EmbeddedResource = 'embeddedResource', Resource = 'resource', FileEdit = 'fileEdit', + Terminal = 'terminal', } /** @@ -869,12 +899,29 @@ export interface IToolResultFileEditContent { }; } +/** + * A reference to a terminal whose output is relevant to this tool result. + * + * Clients can subscribe to the terminal's URI to stream its output in real + * time, providing live feedback while a tool is executing. + * + * @category Tool Result Content + */ +export interface IToolResultTerminalContent { + type: ToolResultContentType.Terminal; + /** Terminal URI (subscribable for full terminal state) */ + resource: URI; + /** Display title for the terminal content */ + title: string; +} + /** * Content block in a tool result. * * Mirrors the content blocks in MCP `CallToolResult.content`, plus - * `IToolResultResourceContent` for lazy-loading large results and - * `IToolResultFileEditContent` for file edit diffs (AHP extensions). + * `IToolResultResourceContent` for lazy-loading large results, + * `IToolResultFileEditContent` for file edit diffs, and + * `IToolResultTerminalContent` for live terminal output (AHP extensions). * * @category Tool Result Content */ @@ -882,7 +929,8 @@ export type IToolResultContent = | IToolResultTextContent | IToolResultEmbeddedResourceContent | IToolResultResourceContent - | IToolResultFileEditContent; + | IToolResultFileEditContent + | IToolResultTerminalContent; // ─── Customization Types ───────────────────────────────────────────────────── @@ -950,6 +998,95 @@ export interface ISessionCustomization { statusMessage?: string; } +// ─── Terminal Types ────────────────────────────────────────────────────────── + +/** + * Lightweight terminal metadata exposed on the root state. + * + * @category Terminal Types + */ +export interface ITerminalInfo { + /** Terminal URI (subscribable for full terminal state) */ + resource: URI; + /** Human-readable terminal title */ + title: string; + /** Who currently holds this terminal */ + claim: ITerminalClaim; + /** Process exit code, if the terminal process has exited */ + exitCode?: number; +} + +/** + * Discriminant for terminal claim kinds. + * + * @category Terminal Types + */ +export const enum TerminalClaimKind { + Client = 'client', + Session = 'session', +} + +/** + * A terminal claimed by a connected client. + * + * @category Terminal Types + */ +export interface ITerminalClientClaim { + /** Discriminant */ + kind: TerminalClaimKind.Client; + /** The `clientId` of the claiming client */ + clientId: string; +} + +/** + * A terminal claimed by a session, optionally scoped to a specific turn or tool call. + * + * @category Terminal Types + */ +export interface ITerminalSessionClaim { + /** Discriminant */ + kind: TerminalClaimKind.Session; + /** Session URI that claimed the terminal */ + session: URI; + /** Optional turn identifier within the session */ + turnId?: string; + /** Optional tool call identifier within the turn */ + toolCallId?: string; +} + +/** + * Describes who currently holds a terminal. A terminal may be claimed by + * either a connected client or a session (e.g. during a tool call). + * + * @category Terminal Types + */ +export type ITerminalClaim = ITerminalClientClaim | ITerminalSessionClaim; + +/** + * Full state for a single terminal, loaded when a client subscribes to the terminal's URI. + * + * @category Terminal Types + */ +export interface ITerminalState { + /** Human-readable terminal title */ + title: string; + /** Current working directory of the terminal process */ + cwd?: URI; + /** Terminal width in columns */ + cols?: number; + /** Terminal height in rows */ + rows?: number; + /** + * Accumulated terminal output. May contain ANSI escape sequences. + * The scrollback length is implementation-defined. + */ + content: string; + /** Process exit code, set when the terminal process exits */ + exitCode?: number; + /** Who currently holds this terminal */ + claim: ITerminalClaim; +} + // ─── Common Types ──────────────────────────────────────────────────────────── /** @@ -988,7 +1125,7 @@ export interface ISnapshot { /** The subscribed resource URI (e.g. `agenthost:/root` or `copilot:/`) */ resource: URI; /** The current state of the resource */ - state: IRootState | ISessionState; + state: IRootState | ISessionState | ITerminalState; /** 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/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 11d2bb4b017dc..cfbdf558bb534 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -37,6 +37,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe [ActionType.SessionToolCallConfirmed]: 1, [ActionType.SessionToolCallComplete]: 1, [ActionType.SessionToolCallResultConfirmed]: 1, + [ActionType.SessionToolCallContentChanged]: 1, [ActionType.SessionTurnComplete]: 1, [ActionType.SessionTurnCancelled]: 1, [ActionType.SessionError]: 1, @@ -53,6 +54,17 @@ export const ACTION_INTRODUCED_IN: { readonly [K in IStateAction['type']]: numbe [ActionType.SessionCustomizationsChanged]: 1, [ActionType.SessionCustomizationToggled]: 1, [ActionType.SessionTruncated]: 1, + [ActionType.SessionIsReadChanged]: 1, + [ActionType.SessionIsDoneChanged]: 1, + [ActionType.RootTerminalsChanged]: 1, + [ActionType.TerminalData]: 1, + [ActionType.TerminalInput]: 1, + [ActionType.TerminalResized]: 1, + [ActionType.TerminalClaimed]: 1, + [ActionType.TerminalTitleChanged]: 1, + [ActionType.TerminalCwdChanged]: 1, + [ActionType.TerminalExited]: 1, + [ActionType.TerminalCleared]: 1, }; /** diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index 130e15a2c3470..a86e7107412dd 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -47,6 +47,8 @@ export { type ISessionPendingMessageSetAction, type ISessionPendingMessageRemovedAction, type ISessionQueuedMessagesReorderedAction, + type ISessionIsReadChangedAction, + type ISessionIsDoneChangedAction, type IStateAction, } from './protocol/actions.js'; @@ -85,6 +87,8 @@ import type { ISessionPendingMessageSetAction, ISessionPendingMessageRemovedAction, ISessionQueuedMessagesReorderedAction, + ISessionIsReadChangedAction, + ISessionIsDoneChangedAction, } from './protocol/actions.js'; import type { IProtocolNotification } from './protocol/notifications.js'; @@ -123,6 +127,8 @@ export type ICustomizationToggledAction = import('./protocol/actions.js').ISessi export type IPendingMessageSetAction = ISessionPendingMessageSetAction; export type IPendingMessageRemovedAction = ISessionPendingMessageRemovedAction; export type IQueuedMessagesReorderedAction = ISessionQueuedMessagesReorderedAction; +export type IIsReadChangedAction = ISessionIsReadChangedAction; +export type IIsDoneChangedAction = ISessionIsDoneChangedAction; // Notifications export type INotification = IProtocolNotification; diff --git a/src/vs/platform/agentHost/common/state/sessionClientState.ts b/src/vs/platform/agentHost/common/state/sessionClientState.ts index 722382508e48b..631b1c1c32cbb 100644 --- a/src/vs/platform/agentHost/common/state/sessionClientState.ts +++ b/src/vs/platform/agentHost/common/state/sessionClientState.ts @@ -20,7 +20,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { IActionEnvelope, INotification, ISessionAction, isRootAction, isSessionAction, IStateAction } from './sessionActions.js'; import { rootReducer, sessionReducer } from './sessionReducers.js'; -import { IRootState, ISessionState, ROOT_STATE_URI } from './sessionState.js'; +import { IRootState, ISessionState, ITerminalState, ROOT_STATE_URI } from './sessionState.js'; import { ILogService } from '../../../log/common/log.js'; // ---- Pending action tracking ------------------------------------------------ @@ -110,7 +110,7 @@ export class SessionClientState extends Disposable { * Apply a state snapshot received from the server (from handshake, * subscribe response, or reconnection). */ - handleSnapshot(resource: string, state: IRootState | ISessionState, fromSeq: number): void { + handleSnapshot(resource: string, state: IRootState | ISessionState | ITerminalState, fromSeq: number): void { this._lastSeenServerSeq = Math.max(this._lastSeenServerSeq, fromSeq); if (resource === ROOT_STATE_URI) { diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 6b9ddb22fcaf0..14a979e86cf76 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -43,6 +43,7 @@ export { type ISessionState, type ISessionSummary, type ISnapshot, + type ITerminalState, type IToolAnnotations, type IToolCallCancelledState, type IToolCallCompletedState, diff --git a/src/vs/platform/agentHost/common/tunnelAgentHost.ts b/src/vs/platform/agentHost/common/tunnelAgentHost.ts new file mode 100644 index 0000000000000..55812bbb8959f --- /dev/null +++ b/src/vs/platform/agentHost/common/tunnelAgentHost.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const ITunnelAgentHostService = createDecorator('tunnelAgentHostService'); + +/** + * IPC channel name for the shared-process tunnel service. + */ +export const TUNNEL_AGENT_HOST_CHANNEL = 'tunnelAgentHost'; + +/** Configuration key for the list of manually configured tunnel names. */ +export const TunnelAgentHostsSettingId = 'chat.remoteAgentTunnels'; + +/** Minimum protocol version required for agent host connections. */ +export const TUNNEL_MIN_PROTOCOL_VERSION = 5; + +/** Well-known port for the agent host on tunnel machines. */ +export const TUNNEL_AGENT_HOST_PORT = 31546; + +/** Label used to identify VS Code server launcher tunnels. */ +export const TUNNEL_LAUNCHER_LABEL = 'vscode-server-launcher'; + +/** Address prefix for tunnel-backed connections (e.g. `tunnel:myTunnelId`). */ +export const TUNNEL_ADDRESS_PREFIX = 'tunnel:'; + +/** Prefix for protocol version tags. */ +export const PROTOCOL_VERSION_TAG_PREFIX = 'protocolv'; + +/** + * Parse tunnel tags to extract display name and protocol version. + * Follows the convention from the vscode-remote-tunnels SDK: the + * first label that is not `vscode-server-launcher`, does not start + * with `_`, and is not a `protocolvN` tag is the display name. + */ +export class TunnelTags { + public readonly protocolVersion: number = 2; + public readonly name: string | undefined; + + constructor(readonly value: readonly string[] | undefined) { + if (value) { + let protocolVersion: number | undefined; + let name: string | undefined; + for (const tag of value) { + if (tag.startsWith(PROTOCOL_VERSION_TAG_PREFIX)) { + const parsed = Number(tag.slice(PROTOCOL_VERSION_TAG_PREFIX.length)); + if (!isNaN(parsed)) { + protocolVersion = parsed; + } + } else if (!tag.startsWith('_') && tag !== TUNNEL_LAUNCHER_LABEL && !name) { + name = tag; + } + } + if (protocolVersion !== undefined) { + this.protocolVersion = protocolVersion; + } + if (name !== undefined) { + this.name = name; + } + } + } +} + +/** A recently used tunnel cached in storage. */ +export interface ICachedTunnel { + readonly tunnelId: string; + readonly clusterId: string; + readonly name: string; + readonly authProvider?: 'github' | 'microsoft'; +} + +/** Information about a discovered dev tunnel with an agent host. */ +export interface ITunnelInfo { + /** The tunnel's unique identifier. */ + readonly tunnelId: string; + /** The cluster region where the tunnel is hosted. */ + readonly clusterId: string; + /** Display name derived from tunnel tags or tunnel name. */ + readonly name: string; + /** All tags/labels on the tunnel. */ + readonly tags: readonly string[]; + /** Parsed protocol version from tags. */ + readonly protocolVersion: number; + /** Number of hosts currently accepting connections (0 = offline). */ + readonly hostConnectionCount: number; +} + +/** + * Serializable result from a successful tunnel connect operation. + * Returned over IPC from the shared process. + */ +export interface ITunnelConnectResult { + /** Unique identifier for this connection's relay channel. */ + readonly connectionId: string; + /** Display-friendly address (e.g. "tunnel:myTunnel"). */ + readonly address: string; + /** Display name for the tunnel. */ + readonly name: string; + /** Connection token derived from the tunnel ID. */ + readonly connectionToken: string; +} + +/** + * A message relayed from a remote agent host through the tunnel. + * The shared process acts as a WebSocket proxy, forwarding JSON + * messages bidirectionally between the tunnel and the renderer via IPC. + */ +export interface ITunnelRelayMessage { + readonly connectionId: string; + readonly data: string; +} + +/** + * Main-process (shared process) service that manages dev tunnel + * connections. The renderer calls this over IPC and handles registration + * with {@link IRemoteAgentHostService} locally. + */ +export const ITunnelAgentHostMainService = createDecorator('tunnelAgentHostMainService'); + +export interface ITunnelAgentHostMainService { + readonly _serviceBrand: undefined; + + /** Fires when a message is received from a remote agent host via the tunnel relay. */ + readonly onDidRelayMessage: Event; + + /** Fires when a relay connection to a remote agent host closes. */ + readonly onDidRelayClose: Event; + + /** + * List dev tunnels associated with the user's account that have + * the `vscode-server-launcher` label and a protocol version tag + * of at least {@link TUNNEL_MIN_PROTOCOL_VERSION}. + * + * @param token The user's access token (GitHub or Microsoft). + * @param authProvider The auth provider that issued the token. + * @param additionalTunnelNames Optional tunnel names to look up + * in addition to the account-wide enumeration. + */ + listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise; + + /** + * Connect to a tunnel's agent host via the dev tunnels relay and + * begin relaying WebSocket messages through IPC. + * + * @param token The user's access token (GitHub or Microsoft). + * @param authProvider The auth provider that issued the token. + * @param tunnelId The tunnel ID to connect to. + * @param clusterId The cluster region of the tunnel. + */ + connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise; + + /** + * Send a message to a remote agent host through the tunnel relay. + */ + relaySend(connectionId: string, message: string): Promise; + + /** + * Disconnect a tunnel relay connection. + */ + disconnect(connectionId: string): Promise; +} + +/** + * Renderer-side service that manages dev tunnel agent host connections. + * Uses the shared-process {@link ITunnelAgentHostMainService} for + * actual tunnel SDK operations and registers connections with + * {@link IRemoteAgentHostService}. + */ +export interface ITunnelAgentHostService { + readonly _serviceBrand: undefined; + + /** Fires when the set of available tunnels changes. */ + readonly onDidChangeTunnels: Event; + + /** + * Enumerate available dev tunnels with agent host support. + * When {@link options.silent} is `true`, uses cached tokens without + * prompting the user. Returns an empty array if no cached token. + */ + listTunnels(options?: { silent?: boolean }): Promise; + + /** + * Connect to a tunnel's agent host and register the connection + * with {@link IRemoteAgentHostService}. + * + * @param tunnel The tunnel to connect to. + * @param authProvider Optional auth provider to use. If omitted, uses cached/last known. + */ + connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): Promise; + + /** + * Disconnect from a tunnel agent host. + */ + disconnect(address: string): Promise; + + /** Get the list of recently used (cached) tunnels. */ + getCachedTunnels(): ICachedTunnel[]; + + /** Cache a tunnel as recently used. */ + cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void; + + /** Remove a tunnel from the cache. */ + removeCachedTunnel(tunnelId: string): void; + + /** + * Determine which auth provider has an existing cached session. + * When {@link silent} is true, does not prompt the user. + * Returns `undefined` if no cached session is available. + */ + getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined>; +} diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index d75cd8c6062dd..301f0c3e0d0e2 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -13,7 +13,7 @@ import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js' import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; @@ -83,18 +83,9 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { // ---- IAgentService forwarding (no await needed, delayed channel handles queuing) ---- - getResourceMetadata(): Promise { - return this._proxy.getResourceMetadata(); - } authenticate(params: IAuthenticateParams): Promise { return this._proxy.authenticate(params); } - listAgents(): Promise { - return this._proxy.listAgents(); - } - refreshModels(): Promise { - return this._proxy.refreshModels(); - } listSessions(): Promise { return this._proxy.listSessions(); } diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index cef610a4d23f4..539bedf049594 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -15,7 +15,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; -import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; @@ -150,32 +150,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return session; } - /** - * Retrieve the server's resource metadata describing auth requirements. - */ - async getResourceMetadata(): Promise { - return await this._sendExtensionRequest('getResourceMetadata') as IResourceMetadata; - } - /** * Authenticate with the remote agent host using a specific scheme. */ async authenticate(params: IAuthenticateParams): Promise { - return await this._sendExtensionRequest('authenticate', params) as IAuthenticateResult; - } - - /** - * Refresh the model list from all providers on the remote host. - */ - async refreshModels(): Promise { - await this._sendExtensionRequest('refreshModels'); - } - - /** - * Discover available agent backends from the remote host. - */ - async listAgents(): Promise { - return await this._sendExtensionRequest('listAgents') as IAgentDescriptor[]; + await this._sendRequest('authenticate', params); + return { authenticated: true }; } /** @@ -203,6 +183,8 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC modifiedTime: s.modifiedAt, summary: s.title, workingDirectory: typeof s.workingDirectory === 'string' ? toAgentHostUri(URI.parse(s.workingDirectory), this._connectionAuthority) : undefined, + isRead: s.isRead, + isDone: s.isDone, })); } diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts index 20bf4ff37cc92..47ca31ca446bb 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostServiceImpl.ts @@ -18,14 +18,20 @@ import type { IAgentConnection } from '../common/agentService.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, + RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, + entryToRawEntry, + getEntryAddress, + rawEntryToEntry, + type IRawRemoteAgentHostEntry, type IRemoteAgentHostConnectionInfo, type IRemoteAgentHostEntry, } from '../common/remoteAgentHostService.js'; import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; import { WebSocketClientTransport } from './webSocketClientTransport.js'; import { normalizeRemoteAgentHostAddress } from '../common/agentHostUri.js'; +import { isDefined } from '../../../base/common/types.js'; /** Tracks a single remote connection through its lifecycle. */ interface IConnectionEntry { @@ -90,7 +96,12 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } get configuredEntries(): readonly IRemoteAgentHostEntry[] { - return this._getConfiguredEntries().map(e => ({ ...e, address: normalizeRemoteAgentHostAddress(e.address) })); + return this._getConfiguredEntries().map(e => { + if (e.connection.type === RemoteAgentHostEntryType.Tunnel) { + return e; + } + return { ...e, connection: { ...e.connection, address: normalizeRemoteAgentHostAddress(e.connection.address) } }; + }); } getConnection(address: string): IAgentConnection | undefined { @@ -102,11 +113,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo reconnect(address: string): void { const normalized = normalizeRemoteAgentHostAddress(address); - // SSH entries are reconnected by the SSH service, not via WebSocket + // SSH/tunnel entries are reconnected by their respective services const configuredEntry = this._getConfiguredEntries().find( - e => normalizeRemoteAgentHostAddress(e.address) === normalized + e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalized ); - if (configuredEntry?.sshConfigHost) { + if (configuredEntry && configuredEntry.connection.type !== RemoteAgentHostEntryType.WebSocket) { return; } @@ -132,8 +143,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo throw new Error('Remote agent host connections are not enabled.'); } - const entry: IRemoteAgentHostEntry = { ...input, address: normalizeRemoteAgentHostAddress(input.address) }; - const existingConnection = this._getConnectionInfo(entry.address); + const entry: IRemoteAgentHostEntry = input.connection.type === RemoteAgentHostEntryType.Tunnel + ? input + : { ...input, connection: { ...input.connection, address: normalizeRemoteAgentHostAddress(input.connection.address) } }; + const address = getEntryAddress(entry); + const existingConnection = this._getConnectionInfo(address); await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); if (existingConnection) { @@ -143,24 +157,36 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo }; } - const connectedConnection = this._getConnectionInfo(entry.address); + // SSH entries are connected externally — just persist + // the entry and return a disconnected placeholder. The connection + // will be established by the SSH contribution. + if (entry.connection.type === RemoteAgentHostEntryType.SSH) { + return { + address, + name: entry.name, + clientId: '', + status: RemoteAgentHostConnectionStatus.Disconnected, + }; + } + + const connectedConnection = this._getConnectionInfo(address); if (connectedConnection) { return connectedConnection; } - const wait = this._getOrCreateConnectionWait(entry.address); + const wait = this._getOrCreateConnectionWait(address); const connection = await raceTimeout(wait.p, RemoteAgentHostService.ConnectionWaitTimeout, () => { - this._pendingConnectionWaits.delete(entry.address); + this._pendingConnectionWaits.delete(address); }); if (!connection) { - throw new Error(`Timed out connecting to ${entry.address}`); + throw new Error(`Timed out connecting to ${address}`); } return connection; } async addSSHConnection(entry: IRemoteAgentHostEntry, connection: IAgentConnection): Promise { - const address = entry.address; + const address = getEntryAddress(entry); // Dispose any existing entry for this address to avoid leaking // old protocol clients and relay transports on reconnect. @@ -190,8 +216,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } })); - // Persist SSH entries — await so that the config is written before + // Persist entries — await so that the config is written before // onDidChangeConnections fires, ensuring _reconcile creates the provider. + // Tunnel entries are filtered out by _storeConfiguredEntries automatically. await this._storeConfiguredEntries(this._upsertConfiguredEntry(entry)); this._onDidChangeConnections.fire(); @@ -210,7 +237,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // This setting is only used in the sessions app (user scope), so we // don't need to inspect per-scope values like _upsertConfiguredEntry does. const entries = this._getConfiguredEntries().filter( - e => normalizeRemoteAgentHostAddress(e.address) !== normalized + e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) !== normalized ); await this._storeConfiguredEntries(entries); @@ -246,9 +273,9 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo return; } - const rawEntries: IRemoteAgentHostEntry[] = this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []; - const entries = rawEntries.map(e => ({ ...e, address: normalizeRemoteAgentHostAddress(e.address) })); - const desired = new Set(entries.map(e => e.address)); + const rawEntries = (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); + const entriesWithAddress = rawEntries.map(e => ({ entry: e, address: normalizeRemoteAgentHostAddress(getEntryAddress(e)) })); + const desired = new Set(entriesWithAddress.map(e => e.address)); this._logService.info(`[RemoteAgentHost] Reconciling: desired=[${[...desired].join(', ')}], current=[${[...this._entries.keys()].map(a => `${a}(${this._entries.get(a)!.connected ? 'connected' : 'pending'})`).join(', ')}]`); @@ -257,10 +284,10 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo const oldNames = new Map(this._names); this._names.clear(); this._tokens.clear(); - for (const entry of entries) { - this._names.set(entry.address, entry.name); - this._tokens.set(entry.address, entry.connectionToken); - if (this._entries.has(entry.address) && oldNames.get(entry.address) !== entry.name) { + for (const { entry, address } of entriesWithAddress) { + this._names.set(address, entry.name); + this._tokens.set(address, entry.connectionToken); + if (this._entries.has(address) && oldNames.get(address) !== entry.name) { namesChanged = true; } } @@ -275,10 +302,11 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } } - // Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService) - for (const entry of entries) { - if (!this._entries.has(entry.address) && !entry.sshConfigHost) { - this._connectTo(entry.address, entry.connectionToken); + // Add new connections (skip SSH entries — those are handled by ISSHRemoteAgentHostService, + // and skip tunnel entries — those are handled by ITunnelAgentHostService) + for (const { entry, address } of entriesWithAddress) { + if (!this._entries.has(address) && entry.connection.type === RemoteAgentHostEntryType.WebSocket) { + this._connectTo(address, entry.connectionToken); } } @@ -389,7 +417,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo /** Check whether the given normalized address is still in the configured entries. */ private _isAddressConfigured(address: string): boolean { const entries = this._getConfiguredEntries(); - return entries.some(e => normalizeRemoteAgentHostAddress(e.address) === address); + return entries.some(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === address); } private _getConnectionInfo(address: string): IRemoteAgentHostConnectionInfo | undefined { @@ -397,7 +425,7 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } private _getConfiguredEntries(): IRemoteAgentHostEntry[] { - return this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []; + return (this._configurationService.getValue(RemoteAgentHostsSettingId) ?? []).map(rawEntryToEntry).filter(isDefined); } private _upsertConfiguredEntry(entry: IRemoteAgentHostEntry): IRemoteAgentHostEntry[] { @@ -405,27 +433,28 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo // merge entries from an overriding scope (e.g. workspace) into the // user scope and then lose them on the next read. const target = this._getConfigurationTarget(); - const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); - let configuredEntries: readonly IRemoteAgentHostEntry[]; + const inspected = this._configurationService.inspect(RemoteAgentHostsSettingId); + let configuredRaw: readonly IRawRemoteAgentHostEntry[]; switch (target) { case ConfigurationTarget.USER_LOCAL: - configuredEntries = inspected.userLocalValue ?? []; + configuredRaw = inspected.userLocalValue ?? []; break; case ConfigurationTarget.USER_REMOTE: - configuredEntries = inspected.userRemoteValue ?? []; + configuredRaw = inspected.userRemoteValue ?? []; break; default: - configuredEntries = inspected.userValue ?? []; + configuredRaw = inspected.userValue ?? []; break; } - const normalizedAddress = normalizeRemoteAgentHostAddress(entry.address); - const existingIndex = configuredEntries.findIndex(configuredEntry => normalizeRemoteAgentHostAddress(configuredEntry.address) === normalizedAddress); + const configuredEntries = configuredRaw.map(rawEntryToEntry).filter((e): e is IRemoteAgentHostEntry => e !== undefined); + const normalizedAddress = normalizeRemoteAgentHostAddress(getEntryAddress(entry)); + const existingIndex = configuredEntries.findIndex(e => normalizeRemoteAgentHostAddress(getEntryAddress(e)) === normalizedAddress); if (existingIndex === -1) { return [...configuredEntries, entry]; } - return configuredEntries.map((configuredEntry, index) => index === existingIndex ? entry : configuredEntry); + return configuredEntries.map((e, index) => index === existingIndex ? entry : e); } private _getConfigurationTarget(): ConfigurationTarget { @@ -443,7 +472,8 @@ export class RemoteAgentHostService extends Disposable implements IRemoteAgentHo } private async _storeConfiguredEntries(entries: IRemoteAgentHostEntry[]): Promise { - await this._configurationService.updateValue(RemoteAgentHostsSettingId, entries, this._getConfigurationTarget()); + const raw = entries.map(entryToRawEntry).filter(isDefined); + await this._configurationService.updateValue(RemoteAgentHostsSettingId, raw, this._getConfigurationTarget()); } private _getOrCreateConnectionWait(address: string): DeferredPromise { diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index 21595d6adb0de..035d89132e82e 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -9,7 +9,7 @@ import { ILogService } from '../../log/common/log.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { ISharedProcessService } from '../../ipc/electron-browser/services.js'; import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IRemoteAgentHostService } from '../common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostEntryType } from '../common/remoteAgentHostService.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { SSHRelayTransport } from './sshRelayTransport.js'; import { RemoteAgentHostProtocolClient } from './remoteAgentHostProtocolClient.js'; @@ -92,10 +92,13 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA this._logService.trace('[SSHRemoteAgentHost] Protocol handshake completed'); await this._remoteAgentHostService.addSSHConnection({ - address: result.address, name: result.name, connectionToken: result.connectionToken, - sshConfigHost: result.sshConfigHost, + connection: { + type: RemoteAgentHostEntryType.SSH, + address: result.address, + sshConfigHost: result.sshConfigHost, + }, }, protocolClient); } catch (err) { this._logService.error('[SSHRemoteAgentHost] Connection setup failed', err); @@ -141,10 +144,13 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA await protocolClient.connect(); await this._remoteAgentHostService.addSSHConnection({ - address: result.address, name: result.name, connectionToken: result.connectionToken, - sshConfigHost: result.sshConfigHost, + connection: { + type: RemoteAgentHostEntryType.SSH, + address: result.address, + sshConfigHost: result.sshConfigHost, + }, }, protocolClient); const handle = new SSHAgentHostConnectionHandle( diff --git a/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts new file mode 100644 index 0000000000000..6a6341b72ee35 --- /dev/null +++ b/src/vs/platform/agentHost/electron-browser/tunnelRelayTransport.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * 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 } from '../../../base/common/lifecycle.js'; +import type { IAhpServerNotification, IJsonRpcResponse, IProtocolMessage } from '../common/state/sessionProtocol.js'; +import type { IProtocolTransport } from '../common/state/sessionTransport.js'; +import type { ITunnelAgentHostMainService, ITunnelRelayMessage } from '../common/tunnelAgentHost.js'; + +/** + * A protocol transport that relays messages through the shared process + * tunnel relay via IPC, instead of using a direct WebSocket connection. + * + * The shared process manages the actual dev tunnel relay connection + * and forwards messages bidirectionally through this IPC channel. + */ +export class TunnelRelayTransport extends Disposable implements IProtocolTransport { + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + constructor( + private readonly _connectionId: string, + private readonly _tunnelService: ITunnelAgentHostMainService, + ) { + super(); + + // Listen for relay messages from the shared process + this._register(this._tunnelService.onDidRelayMessage((msg: ITunnelRelayMessage) => { + if (msg.connectionId === this._connectionId) { + try { + const parsed = JSON.parse(msg.data) as IProtocolMessage; + this._onMessage.fire(parsed); + } catch { + // Malformed message — drop + } + } + })); + + // Listen for relay close + this._register(this._tunnelService.onDidRelayClose((closedId: string) => { + if (closedId === this._connectionId) { + this._onClose.fire(); + } + })); + } + + override dispose(): void { + // Tear down the shared-process relay connection + this._tunnelService.disconnect(this._connectionId).catch(() => { /* best effort */ }); + super.dispose(); + } + + send(message: IProtocolMessage | IAhpServerNotification | IJsonRpcResponse): void { + this._tunnelService.relaySend(this._connectionId, JSON.stringify(message)).catch(() => { + // Send failed — connection probably closed + }); + } +} diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6612067ee1799..460f3565e11f2 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -11,7 +11,7 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; +import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; @@ -91,20 +91,6 @@ export class AgentService extends Disposable implements IAgentService { // ---- auth --------------------------------------------------------------- - async listAgents(): Promise { - return [...this._providers.values()].map(p => p.getDescriptor()); - } - - async getResourceMetadata(): Promise { - const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); - return { resources }; - } - - getResourceMetadataSync(): IResourceMetadata { - const resources = [...this._providers.values()].flatMap(p => p.getProtectedResources()); - return { resources }; - } - async authenticate(params: IAuthenticateParams): Promise { this._logService.trace(`[AgentService] authenticate called: resource=${params.resource}`); for (const provider of this._providers.values()) { @@ -136,15 +122,27 @@ export class AgentService extends Disposable implements IAgentService { return s; } try { - const customTitle = await ref.object.getMetadata('customTitle'); + const [customTitle, isReadRaw, isDoneRaw] = await Promise.all([ + ref.object.getMetadata('customTitle'), + ref.object.getMetadata('isRead'), + ref.object.getMetadata('isDone'), + ]); + let updated = s; if (customTitle) { - return { ...s, summary: customTitle }; + updated = { ...updated, summary: customTitle }; + } + if (isReadRaw !== undefined) { + updated = { ...updated, isRead: isReadRaw === 'true' }; + } + if (isDoneRaw !== undefined) { + updated = { ...updated, isDone: isDoneRaw === 'true' }; } + return updated; } finally { ref.dispose(); } - } catch { - // ignore — title overlay is best-effort + } catch (e) { + this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e); } return s; })); @@ -153,15 +151,6 @@ export class AgentService extends Disposable implements IAgentService { return result; } - /** - * Refreshes the model list from all providers and publishes the updated - * agents (with their models) to root state via `root/agentsChanged`. - */ - async refreshModels(): Promise { - this._logService.trace('[AgentService] refreshModels called'); - this._updateAgents(); - } - async createSession(config?: IAgentCreateSessionConfig): Promise { const providerId = config?.provider ?? this._defaultProvider; const provider = providerId ? this._providers.get(providerId) : undefined; @@ -325,24 +314,36 @@ export class AgentService extends Disposable implements IAgentService { } const turns = this._buildTurnsFromMessages(messages); - // Check for a persisted custom title in the session database + // Check for persisted metadata in the session database let title = meta.summary ?? 'Session'; + let isRead: boolean | undefined; + let isDone: boolean | undefined; const ref = this._sessionDataService.tryOpenDatabase?.(session); if (ref) { try { const db = await ref; if (db) { try { - const customTitle = await db.object.getMetadata('customTitle'); + const [customTitle, isReadRaw, isDoneRaw] = await Promise.all([ + db.object.getMetadata('customTitle'), + db.object.getMetadata('isRead'), + db.object.getMetadata('isDone'), + ]); if (customTitle) { title = customTitle; } + if (isReadRaw !== undefined) { + isRead = isReadRaw === 'true'; + } + if (isDoneRaw !== undefined) { + isDone = isDoneRaw === 'true'; + } } finally { db.dispose(); } } } catch { - // Best-effort: fall back to agent-provided title + // Best-effort: fall back to agent-provided metadata } } @@ -354,6 +355,8 @@ export class AgentService extends Disposable implements IAgentService { createdAt: meta.startTime, modifiedAt: meta.modifiedTime, workingDirectory: meta.workingDirectory?.toString(), + isRead, + isDone, }; this._stateManager.restoreSession(summary, turns); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 8a3de0b49f03f..0ffd7095ef7fa 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -87,7 +87,11 @@ export class AgentSideEffects extends Disposable { } catch { models = []; } - return { provider: d.provider, displayName: d.displayName, description: d.description, models }; + const protectedResources = a.getProtectedResources(); + return { + provider: d.provider, displayName: d.displayName, description: d.description, models, + protectedResources: protectedResources.length > 0 ? protectedResources : undefined, + }; })); this._stateManager.dispatchServerAction({ type: ActionType.RootAgentsChanged, agents: infos }); } @@ -380,6 +384,14 @@ export class AgentSideEffects extends Disposable { agent?.setCustomizationEnabled?.(action.uri, action.enabled); break; } + case ActionType.SessionIsReadChanged: { + this._persistSessionFlag(action.session, 'isRead', action.isRead ? 'true' : ''); + break; + } + case ActionType.SessionIsDoneChanged: { + this._persistSessionFlag(action.session, 'isDone', action.isDone ? 'true' : ''); + break; + } } } @@ -392,6 +404,15 @@ export class AgentSideEffects extends Disposable { }); } + private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { + const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); + ref.object.setMetadata(key, value).catch(err => { + this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err); + }).finally(() => { + ref.dispose(); + }); + } + /** * Pushes the current pending message state from the session to the agent. * The server controls queued message consumption; only steering messages diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 41869a4d4665a..1cdfe2fd92b82 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -9,7 +9,6 @@ import { SequencerByKey } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { FileAccess } from '../../../../base/common/network.js'; -import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -25,6 +24,7 @@ import { CopilotAgentSession, SessionWrapperFactory } from './copilotAgentSessio import { parsedPluginsEqual, toSdkCustomAgents, toSdkHooks, toSdkMcpServers, toSdkSkillDirectories } from './copilotPluginConverters.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { forkCopilotSessionOnDisk, getCopilotDataDir, truncateCopilotSessionOnDisk } from './copilotAgentForking.js'; +import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; /** * Agent provider backed by the Copilot SDK {@link CopilotClient}. @@ -59,16 +59,16 @@ export class CopilotAgent extends Disposable implements IAgent { provider: 'copilot', displayName: 'Agent Host - Copilot', description: 'Copilot SDK agent running in a dedicated process', - requiresAuth: true, }; } - getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + getProtectedResources(): IProtectedResourceMetadata[] { return [{ resource: 'https://api.github.com', resource_name: 'GitHub Copilot', authorization_servers: ['https://github.com/login/oauth'], scopes_supported: ['read:user', 'user:email'], + required: true, }]; } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 311d823078540..b0b0ea49410df 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -10,11 +10,12 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { ILogService } from '../../log/common/log.js'; import { AHPFileSystemProvider } from '../common/agentHostFileSystemProvider.js'; -import { AgentSession, type IAgentService, type IAuthenticateParams } from '../common/agentService.js'; +import { AgentSession, type IAgentService } from '../common/agentService.js'; import type { ICommandMap } from '../common/state/protocol/messages.js'; import { IActionEnvelope, INotification, isSessionAction, type ISessionAction } from '../common/state/sessionActions.js'; import { MIN_PROTOCOL_VERSION, PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { + AHP_AUTH_REQUIRED, AHP_PROVIDER_NOT_FOUND, AHP_SESSION_NOT_FOUND, AHP_UNSUPPORTED_PROTOCOL_VERSION, @@ -59,7 +60,7 @@ function jsonRpcErrorFrom(id: number, err: unknown): IJsonRpcResponse { * Methods handled by the request dispatcher. Excludes `initialize` and * `reconnect` which are handled during the handshake phase. */ -type RequestMethod = Exclude; +type RequestMethod = Exclude; /** * Typed handler map: each key is a request method, each value is a handler @@ -385,6 +386,8 @@ export class ProtocolServerHandler extends Disposable { createdAt: s.startTime, modifiedAt: s.modifiedTime, workingDirectory: s.workingDirectory?.toString(), + isRead: s.isRead, + isDone: s.isDone, })); return { items }; }, @@ -425,6 +428,19 @@ export class ProtocolServerHandler extends Disposable { resourceMove: async (_client, params) => { return this._agentService.resourceMove(params); }, + createTerminal: async () => { + return null; + }, + disposeTerminal: async () => { + return null; + }, + authenticate: async (_client, params) => { + const result = await this._agentService.authenticate(params); + if (!result.authenticated) { + throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication failed for resource: ' + params.resource); + } + return {}; + }, }; @@ -496,21 +512,8 @@ export class ProtocolServerHandler extends Disposable { * protocol. Returns a Promise if the method was recognized, undefined * otherwise. */ - private _handleExtensionRequest(method: string, params: unknown): Promise | undefined { + private _handleExtensionRequest(method: string, _params: unknown): Promise | undefined { switch (method) { - case 'getResourceMetadata': - return this._agentService.getResourceMetadata(); - case 'authenticate': { - const authParams = params as IAuthenticateParams; - if (!authParams || typeof authParams.resource !== 'string' || typeof authParams.token !== 'string') { - return Promise.reject(new ProtocolError(-32602, 'Invalid authenticate params')); - } - return this._agentService.authenticate(authParams); - } - case 'refreshModels': - return this._agentService.refreshModels(); - case 'listAgents': - return this._agentService.listAgents(); case 'shutdown': return this._agentService.shutdown(); default: diff --git a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts new file mode 100644 index 0000000000000..da92f115f930a --- /dev/null +++ b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts @@ -0,0 +1,333 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Tunnel } from '@microsoft/dev-tunnels-contracts'; +import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; +import { createHash } from 'crypto'; +import type WebSocket from 'ws'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { + ITunnelAgentHostMainService, + TUNNEL_ADDRESS_PREFIX, + TUNNEL_AGENT_HOST_PORT, + TUNNEL_LAUNCHER_LABEL, + TUNNEL_MIN_PROTOCOL_VERSION, + TunnelTags, + type ITunnelConnectResult, + type ITunnelInfo, + type ITunnelRelayMessage, +} from '../common/tunnelAgentHost.js'; + +const LOG_PREFIX = '[TunnelAgentHost]'; + +/** + * Derive a connection token from a tunnel ID using the same convention + * as the VS Code CLI (see `get_connection_token` in cli/src/commands/tunnels.rs). + */ +function deriveConnectionToken(tunnelId: string): string { + const hash = createHash('sha256'); + hash.update(tunnelId); + let result = hash.digest('base64url'); + if (result.startsWith('-')) { + result = 'a' + result; + } + return result; +} + +/** State for a single active tunnel relay connection. */ +class TunnelConnection extends Disposable { + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + + private _closed = false; + + constructor( + readonly connectionId: string, + readonly address: string, + readonly name: string, + readonly connectionToken: string, + private readonly _relay: { send: (data: string) => void; close: () => void }, + private readonly _relayClient: { dispose(): void }, + ) { + super(); + } + + override dispose(): void { + if (!this._closed) { + this._closed = true; + this._relay.close(); + this._relayClient.dispose(); + this._onDidClose.fire(); + } + super.dispose(); + } + + relaySend(data: string): void { + this._relay.send(data); + } +} + +export class TunnelAgentHostMainService extends Disposable implements ITunnelAgentHostMainService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidRelayMessage = this._register(new Emitter()); + readonly onDidRelayMessage: Event = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = this._register(new Emitter()); + readonly onDidRelayClose: Event = this._onDidRelayClose.event; + + private readonly _connections = new Map(); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise { + const client = await this._createManagementClient(token, authProvider); + const results: ITunnelInfo[] = []; + const seen = new Set(); + + try { + // Enumerate all tunnels with the vscode-server-launcher label + const tunnels = await client.listTunnels(undefined, undefined, { + labels: [TUNNEL_LAUNCHER_LABEL], + requireAllLabels: true, + includePorts: true, + tokenScopes: ['connect'], + }); + + for (const tunnel of tunnels) { + const info = this._parseTunnelInfo(tunnel); + if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) { + results.push(info); + seen.add(info.tunnelId); + } + } + } catch (err) { + this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, err); + } + + // Look up additional tunnels by name + if (additionalTunnelNames) { + for (const tunnelName of additionalTunnelNames) { + try { + const [tunnel] = await client.listTunnels(undefined, undefined, { + labels: [tunnelName, TUNNEL_LAUNCHER_LABEL], + requireAllLabels: true, + includePorts: true, + tokenScopes: ['connect'], + limit: 1, + }); + if (tunnel) { + const info = this._parseTunnelInfo(tunnel); + if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION && !seen.has(info.tunnelId)) { + results.push(info); + seen.add(info.tunnelId); + } + } + } catch (err) { + this._logService.warn(`${LOG_PREFIX} Failed to look up tunnel '${tunnelName}'`, err); + } + } + } + + this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`); + return results; + } + + async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { + // Tear down any existing connection to this tunnel first. + // Each connect() call creates a fresh relay with its own protocol + // session, so the old one must be closed to avoid conflicts. + for (const [id, conn] of this._connections) { + if (conn.address === `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`) { + this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before reconnecting`); + this._connections.delete(id); + conn.dispose(); + break; + } + } + + const client = await this._createManagementClient(token, authProvider); + const connectionId = generateUuid(); + const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; + + this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`); + + // Get the full tunnel with endpoints and access tokens + const tunnel: Tunnel = { tunnelId, clusterId }; + const resolved = await client.getTunnel(tunnel, { + includePorts: true, + tokenScopes: ['connect'], + }); + + if (!resolved) { + throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); + } + + // Connect to the tunnel relay + const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections'); + const relayClient = new TunnelRelayTunnelClient(client); + relayClient.acceptLocalConnectionsForForwardedPorts = false; + if (resolved.endpoints) { + relayClient.endpoints = resolved.endpoints; + } + + await relayClient.connect(resolved); + this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`); + + // Wait for the agent host port to become available + await relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT); + + // Connect to the forwarded port — returns a Duplex stream + const portStream = await relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT); + this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); + + // Derive connection token from tunnel ID (matches CLI convention) + const connectionToken = deriveConnectionToken(tunnelId); + + // Parse display name from tags + const tags = new TunnelTags(resolved.labels); + const name = tags.name || resolved.name || tunnelId; + + // Create WebSocket over the port stream + const relay = await this._createWebSocketRelay( + portStream, + connectionToken, + connectionId, + ); + + const conn = new TunnelConnection( + connectionId, + address, + name, + connectionToken, + relay, + relayClient, + ); + + conn.onDidClose(() => { + this._connections.delete(connectionId); + this._onDidRelayClose.fire(connectionId); + }); + + this._connections.set(connectionId, conn); + return { connectionId, address, name, connectionToken }; + } + + async relaySend(connectionId: string, message: string): Promise { + const conn = this._connections.get(connectionId); + if (conn) { + conn.relaySend(message); + } + } + + async disconnect(connectionId: string): Promise { + const conn = this._connections.get(connectionId); + if (conn) { + conn.dispose(); + } + } + + private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise { + const mgmt = await import('@microsoft/dev-tunnels-management'); + const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; + + return new mgmt.TunnelManagementHttpClient( + 'vscode-sessions', + mgmt.ManagementApiVersions.Version20230927preview, + async () => authHeader, + ); + } + + private _parseTunnelInfo(tunnel: Tunnel): ITunnelInfo | undefined { + const labels = tunnel.labels ?? []; + const tags = new TunnelTags(labels); + + if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) { + return undefined; + } + + const tunnelId = tunnel.tunnelId; + const clusterId = tunnel.clusterId; + if (!tunnelId || !clusterId) { + return undefined; + } + + const name = tags.name || tunnel.name || tunnelId; + const rawCount = tunnel.status?.hostConnectionCount; + const hostConnectionCount = typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0); + return { + tunnelId, + clusterId, + name, + tags: labels, + protocolVersion: tags.protocolVersion, + hostConnectionCount, + }; + } + + private async _createWebSocketRelay( + portStream: NodeJS.ReadWriteStream, + connectionToken: string, + connectionId: string, + ): Promise<{ send: (data: string) => void; close: () => void }> { + const WS = await import('ws'); + + return new Promise((resolve, reject) => { + // Construct WebSocket URL — the stream is already connected to the right port + let url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}`; + if (connectionToken) { + url += `?tkn=${encodeURIComponent(connectionToken)}`; + } + + // Create WebSocket over the existing stream from the tunnel relay + const ws = new WS.WebSocket(url, { + createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'], + }); + + ws.on('open', () => { + this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`); + resolve({ + send: (data: string) => { + if (ws.readyState === ws.OPEN) { + ws.send(data); + } + }, + close: () => ws.close(), + }); + }); + + ws.on('message', (data: WebSocket.RawData) => { + let text: string; + if (Array.isArray(data)) { + text = Buffer.concat(data).toString(); + } else if (data instanceof ArrayBuffer) { + text = Buffer.from(new Uint8Array(data)).toString(); + } else { + text = data.toString(); + } + this._onDidRelayMessage.fire({ connectionId, data: text }); + }); + + ws.on('close', () => { + const conn = this._connections.get(connectionId); + if (conn) { + conn.dispose(); + } + }); + + ws.on('error', (wsErr: unknown) => { + this._logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`); + reject(wsErr); + }); + }); + } +} diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts index de5c932de3dad..887ed552eadb3 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostService.test.ts @@ -12,7 +12,7 @@ import { TestInstantiationService } from '../../../instantiation/test/common/ins import { IConfigurationService, type IConfigurationChangeEvent } from '../../../configuration/common/configuration.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { RemoteAgentHostService } from '../../electron-browser/remoteAgentHostServiceImpl.js'; -import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; +import { parseRemoteAgentHostInput, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, entryToRawEntry, type IRawRemoteAgentHostEntry, type IRemoteAgentHostEntry } from '../../common/remoteAgentHostService.js'; import { DeferredPromise } from '../../../../base/common/async.js'; // ---- Mock protocol client --------------------------------------------------- @@ -47,7 +47,7 @@ class TestConfigurationService { private readonly _onDidChangeConfiguration = new Emitter>(); readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; - private _entries: IRemoteAgentHostEntry[] = []; + private _entries: IRawRemoteAgentHostEntry[] = []; private _enabled = true; getValue(key?: string): unknown { @@ -64,15 +64,18 @@ class TestConfigurationService { } async updateValue(_key: string, value: unknown): Promise { - this.setEntries((value as IRemoteAgentHostEntry[] | undefined) ?? []); + this._entries = (value as IRawRemoteAgentHostEntry[] | undefined) ?? []; + this._onDidChangeConfiguration.fire({ + affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId, + }); } - get entries(): readonly IRemoteAgentHostEntry[] { + get entries(): readonly IRawRemoteAgentHostEntry[] { return this._entries; } setEntries(entries: IRemoteAgentHostEntry[]): void { - this._entries = entries; + this._entries = entries.map(entryToRawEntry).filter((e): e is IRawRemoteAgentHostEntry => e !== undefined); this._onDidChangeConfiguration.fire({ affectsConfiguration: (key: string) => key === RemoteAgentHostsSettingId || key === RemoteAgentHostsEnabledSettingId, }); @@ -158,7 +161,7 @@ suite('RemoteAgentHostService', () => { }); test('creates connection when setting is updated', async () => { - configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); // Resolve the connect promise assert.strictEqual(createdClients.length, 1); @@ -172,7 +175,7 @@ suite('RemoteAgentHostService', () => { }); test('getConnection returns client after successful connect', async () => { - configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -183,7 +186,7 @@ suite('RemoteAgentHostService', () => { test('removes connection when setting entry is removed', async () => { // Add a connection - configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -197,7 +200,7 @@ suite('RemoteAgentHostService', () => { }); test('fires onDidChangeConnections when connection closes', async () => { - configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); @@ -214,7 +217,7 @@ suite('RemoteAgentHostService', () => { }); test('removes connection on connect failure', async () => { - configService.setEntries([{ address: 'ws://bad:9999', name: 'Bad' }]); + configService.setEntries([{ name: 'Bad', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://bad:9999' } }]); assert.strictEqual(createdClients.length, 1); // Fail the connection and wait for the service to react @@ -228,8 +231,8 @@ suite('RemoteAgentHostService', () => { test('manages multiple connections independently', async () => { configService.setEntries([ - { address: 'ws://host1:8080', name: 'Host 1' }, - { address: 'ws://host2:8080', name: 'Host 2' }, + { name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }, + { name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:8080' } }, ]); assert.strictEqual(createdClients.length, 2); @@ -247,14 +250,14 @@ suite('RemoteAgentHostService', () => { }); test('does not re-create existing connections on setting update', async () => { - configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); const firstClientId = createdClients[0].clientId; // Update setting with same address (but different name) - configService.setEntries([{ address: 'ws://host1:8080', name: 'Renamed' }]); + configService.setEntries([{ name: 'Renamed', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); // Should NOT have created a second client assert.strictEqual(createdClients.length, 1); @@ -271,9 +274,9 @@ suite('RemoteAgentHostService', () => { test('addRemoteAgentHost stores the entry and waits for connection', async () => { const connectionPromise = service.addRemoteAgentHost({ - address: 'ws://host1:8080', name: 'Host 1', connectionToken: 'secret-token', + connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' }, }); assert.deepStrictEqual(configService.entries, [{ @@ -296,14 +299,14 @@ suite('RemoteAgentHostService', () => { }); test('addRemoteAgentHost updates existing configured entries without reconnecting', async () => { - configService.setEntries([{ address: 'ws://host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); const connection = await service.addRemoteAgentHost({ - address: 'ws://host1:8080', name: 'Updated Host', connectionToken: 'new-token', + connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' }, }); assert.strictEqual(createdClients.length, 1); @@ -324,24 +327,24 @@ suite('RemoteAgentHostService', () => { test('addRemoteAgentHost appends when adding a second host', async () => { // Add first host const firstPromise = service.addRemoteAgentHost({ - address: 'host1:8080', name: 'Host 1', + connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' }, }); createdClients[0].connectDeferred.complete(); await firstPromise; // Add second host const secondPromise = service.addRemoteAgentHost({ - address: 'host2:9090', name: 'Host 2', + connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host2:9090' }, }); createdClients[1].connectDeferred.complete(); await secondPromise; assert.strictEqual(createdClients.length, 2); assert.deepStrictEqual(configService.entries, [ - { address: 'host1:8080', name: 'Host 1' }, - { address: 'host2:9090', name: 'Host 2' }, + { address: 'host1:8080', name: 'Host 1', connectionToken: undefined }, + { address: 'host2:9090', name: 'Host 2', connectionToken: undefined }, ]); assert.strictEqual(service.connections.length, 2); }); @@ -350,9 +353,9 @@ suite('RemoteAgentHostService', () => { // Simulate a fast connect: the mock client resolves synchronously // during the config change handler, before addRemoteAgentHost has a // chance to create its DeferredPromise wait. - const originalSetEntries = configService.setEntries.bind(configService); - configService.setEntries = (entries: IRemoteAgentHostEntry[]) => { - originalSetEntries(entries); + const originalUpdateValue = configService.updateValue.bind(configService); + configService.updateValue = async (key: string, value: unknown) => { + await originalUpdateValue(key, value); // Complete the connection synchronously inside the config change callback if (createdClients.length > 0) { createdClients[createdClients.length - 1].connectDeferred.complete(); @@ -360,8 +363,8 @@ suite('RemoteAgentHostService', () => { }; const connection = await service.addRemoteAgentHost({ - address: 'fast-host:1234', name: 'Fast Host', + connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'fast-host:1234' }, }); assert.strictEqual(connection.address, 'fast-host:1234'); @@ -369,7 +372,7 @@ suite('RemoteAgentHostService', () => { }); test('disabling the enabled setting disconnects all remotes', async () => { - configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1); @@ -383,13 +386,13 @@ suite('RemoteAgentHostService', () => { configService.setEnabled(false); await assert.rejects( - () => service.addRemoteAgentHost({ address: 'host1:8080', name: 'Host 1' }), + () => service.addRemoteAgentHost({ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }), /not enabled/, ); }); test('re-enabling reconnects configured remotes', async () => { - configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1); @@ -406,8 +409,8 @@ suite('RemoteAgentHostService', () => { test('removeRemoteAgentHost removes entry and disconnects', async () => { configService.setEntries([ - { address: 'ws://host1:8080', name: 'Host 1' }, - { address: 'ws://host2:9090', name: 'Host 2' }, + { name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host1:8080' } }, + { name: 'Host 2', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'ws://host2:9090' } }, ]); createdClients[0].connectDeferred.complete(); createdClients[1].connectDeferred.complete(); @@ -417,7 +420,7 @@ suite('RemoteAgentHostService', () => { await service.removeRemoteAgentHost('ws://host1:8080'); assert.deepStrictEqual(configService.entries, [ - { address: 'ws://host2:9090', name: 'Host 2' }, + { address: 'ws://host2:9090', name: 'Host 2', connectionToken: undefined }, ]); assert.strictEqual(service.connections.filter(c => c.status === RemoteAgentHostConnectionStatus.Connected).length, 1); assert.strictEqual(service.getConnection('ws://host1:8080'), undefined); @@ -425,7 +428,7 @@ suite('RemoteAgentHostService', () => { }); test('removeRemoteAgentHost normalizes address before removing', async () => { - configService.setEntries([{ address: 'host1:8080', name: 'Host 1' }]); + configService.setEntries([{ name: 'Host 1', connection: { type: RemoteAgentHostEntryType.WebSocket, address: 'host1:8080' } }]); createdClients[0].connectDeferred.complete(); await waitForConnected(); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 0fee52bc488f1..1f8ee25d6fce5 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -86,24 +86,6 @@ suite('AgentService (node dispatcher)', () => { }); }); - // ---- listAgents ----------------------------------------------------- - - suite('listAgents', () => { - - test('returns descriptors from all registered providers', async () => { - service.registerProvider(copilotAgent); - - const agents = await service.listAgents(); - assert.strictEqual(agents.length, 1); - assert.ok(agents.some(a => a.provider === 'copilot')); - }); - - test('returns empty array when no providers are registered', async () => { - const agents = await service.listAgents(); - assert.strictEqual(agents.length, 0); - }); - }); - // ---- createSession -------------------------------------------------- suite('createSession', () => { @@ -211,46 +193,6 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].summary, 'Auto-generated Title'); }); - - test('refreshModels publishes models in root state via agentsChanged', async () => { - service.registerProvider(copilotAgent); - - const envelopes: IActionEnvelope[] = []; - disposables.add(service.onDidAction(e => envelopes.push(e))); - - service.refreshModels(); - - // Model fetch is async inside AgentSideEffects — wait for it - await new Promise(r => setTimeout(r, 50)); - - const agentsChanged = envelopes.find(e => e.action.type === ActionType.RootAgentsChanged); - assert.ok(agentsChanged); - }); - }); - - // ---- getResourceMetadata -------------------------------------------- - - suite('getResourceMetadata', () => { - - test('aggregates protected resources from all providers', async () => { - service.registerProvider(copilotAgent); - - const mockAgent = new MockAgent('other'); - disposables.add(toDisposable(() => mockAgent.dispose())); - service.registerProvider(mockAgent); - - const metadata = await service.getResourceMetadata(); - // copilot agent returns one resource (https://api.github.com), - // generic MockAgent('other') returns empty - assert.deepStrictEqual(metadata, { - resources: [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }], - }); - }); - - test('returns empty resources when no providers registered', async () => { - const metadata = await service.getResourceMetadata(); - assert.deepStrictEqual(metadata, { resources: [] }); - }); }); // ---- authenticate --------------------------------------------------- diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 8bb73997c9b67..e4cd1568dc0ca 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -9,6 +9,7 @@ import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/c import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; import { AgentSession, type AgentProvider, type IAgent, type IAgentAttachment, type IAgentCreateSessionConfig, type IAgentDescriptor, type IAgentMessageEvent, type IAgentModelInfo, type IAgentProgressEvent, type IAgentSessionMetadata, type IAgentToolCompleteEvent, type IAgentToolStartEvent } from '../../common/agentService.js'; +import { IProtectedResourceMetadata } from '../../common/state/protocol/state.js'; import { CustomizationStatus, ToolResultContentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult } from '../../common/state/sessionState.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -48,12 +49,12 @@ export class MockAgent implements IAgent { constructor(readonly id: AgentProvider = 'mock') { } getDescriptor(): IAgentDescriptor { - return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, requiresAuth: this.id === 'copilot' }; + return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent` }; } - getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { + getProtectedResources(): IProtectedResourceMetadata[] { if (this.id === 'copilot') { - return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'] }]; + return [{ resource: 'https://api.github.com', authorization_servers: ['https://github.com/login/oauth'], required: true }]; } return []; } @@ -179,7 +180,7 @@ export class ScriptedMockAgent implements IAgent { } getDescriptor(): IAgentDescriptor { - return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent', requiresAuth: false }; + return { provider: 'mock', displayName: 'Mock Agent', description: 'Scripted test agent' }; } getProtectedResources(): IAuthorizationProtectedResourceMetadata[] { diff --git a/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.ts new file mode 100644 index 0000000000000..f9c2003541905 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/handshake.integrationTest.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 assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; +import { + JSON_RPC_PARSE_ERROR, + type IInitializeResult, + type IJsonRpcErrorResponse, +} from '../../../common/state/sessionProtocol.js'; +import { IServerHandle, nextSessionUri, startServer, TestProtocolClient } from './testHelpers.js'; + +suite('Protocol WebSocket — Handshake & Errors', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + test('handshake returns initialize response with protocol version', async function () { + this.timeout(5_000); + + const result = await client.call('initialize', { + protocolVersion: PROTOCOL_VERSION, + clientId: 'test-handshake', + initialSubscriptions: [URI.from({ scheme: 'agenthost', path: '/root' }).toString()], + }); + + assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); + assert.ok(result.serverSeq >= 0); + assert.ok(result.snapshots.length >= 1, 'should have root state snapshot'); + }); + + test('malformed JSON message returns parse error', async function () { + this.timeout(10_000); + + const raw = new TestProtocolClient(server.port); + await raw.connect(); + + const responsePromise = raw.waitForRawMessage(); + raw.sendRaw('this is not valid json{{{'); + + const response = await responsePromise as IJsonRpcErrorResponse; + assert.strictEqual(response.jsonrpc, '2.0'); + assert.strictEqual(response.id, null); + assert.strictEqual(response.error.code, JSON_RPC_PARSE_ERROR); + + raw.close(); + }); + + test('createSession with invalid provider does not crash server', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-invalid-create' }); + + let gotError = false; + try { + await client.call('createSession', { session: nextSessionUri(), provider: 'nonexistent' }); + } catch { + gotError = true; + } + assert.ok(gotError, 'should have received an error for invalid provider'); + + // Server should still be functional + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as { notification: { type: string } }).notification.type === 'notify/sessionAdded' + ); + assert.ok(notif); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts new file mode 100644 index 0000000000000..988af5838bc10 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts @@ -0,0 +1,327 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ISubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { ISessionAddedNotification, ISessionRemovedNotification } from '../../../common/state/sessionActions.js'; +import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; +import type { INotificationBroadcastParams, IReconnectResult } from '../../../common/state/sessionProtocol.js'; +import type { ISessionState } from '../../../common/state/sessionState.js'; +import { + createAndSubscribeSession, + dispatchTurnStarted, + getActionEnvelope, + IServerHandle, + isActionNotification, + nextSessionUri, + startServer, + TestProtocolClient, +} from './testHelpers.js'; + +suite('Protocol WebSocket — Multi-Client', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + test('sessionAdded notification is broadcast to all connected clients', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-1' }); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-2' }); + + client.clearReceived(); + client2.clearReceived(); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + + const n1 = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const n2 = await client2.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + assert.ok(n1, 'client 1 should receive sessionAdded'); + assert.ok(n2, 'client 2 should receive sessionAdded'); + + const uri1 = ((n1.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + const uri2 = ((n2.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + assert.strictEqual(uri1, uri2, 'both clients should see the same session URI'); + + client2.close(); + }); + + test('sessionRemoved notification is broadcast to all connected clients', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-broadcast-remove-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-remove-2' }); + client2.clearReceived(); + + await client.call('disposeSession', { session: sessionUri }); + + const n1 = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + const n2 = await client2.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + assert.ok(n1, 'client 1 should receive sessionRemoved'); + assert.ok(n2, 'client 2 should receive sessionRemoved even without subscribing'); + + const removed1 = (n1.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + const removed2 = (n2.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + assert.strictEqual(removed1.session.toString(), sessionUri.toString()); + assert.strictEqual(removed2.session.toString(), sessionUri.toString()); + + client2.close(); + }); + + test('two clients on same session both see actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-multi-client-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-multi-client-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client2.clearReceived(); + + dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1); + + const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + assert.ok(d1); + assert.ok(d2); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client2.close(); + }); + + test('client B sends message on session created by client A', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-cross-msg-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-msg-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client.clearReceived(); + client2.clearReceived(); + + // Client B dispatches the turn + dispatchTurnStarted(client2, sessionUri, 'turn-cross', 'hello', 1); + + const r1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const r2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + assert.ok(r1, 'client A should see responsePart from client B turn'); + assert.ok(r2, 'client B should see its own responsePart'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client2.close(); + }); + + test('both clients receive full tool progress updates', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-tool-progress-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-tool-progress-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client.clearReceived(); + client2.clearReceived(); + + dispatchTurnStarted(client, sessionUri, 'turn-tool-mc', 'use-tool', 1); + + // Both clients should see the full tool lifecycle + for (const c of [client, client2]) { + await c.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await c.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + await c.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + await c.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await c.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + } + + client2.close(); + }); + + test('unsubscribe stops receiving session actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-unsubscribe'); + client.notify('unsubscribe', { resource: sessionUri }); + await new Promise(resolve => setTimeout(resolve, 100)); + client.clearReceived(); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-unsub-helper' }); + await client2.call('subscribe', { resource: sessionUri }); + + dispatchTurnStarted(client2, sessionUri, 'turn-unsub', 'hello', 1); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + await new Promise(resolve => setTimeout(resolve, 300)); + const sessionActions = client.receivedNotifications(n => isActionNotification(n, 'session/')); + assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should not receive session actions'); + + client2.close(); + }); + + test('unsubscribed client receives no actions but still gets notifications', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-scoping-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-scoping-2' }); + // Client 2 does NOT subscribe to the session + client2.clearReceived(); + + dispatchTurnStarted(client, sessionUri, 'turn-scoped', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Give some time for any stray actions to arrive + await new Promise(resolve => setTimeout(resolve, 300)); + const sessionActions = client2.receivedNotifications(n => n.method === 'action'); + assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should receive no session actions'); + + // But disposing the session should still broadcast a notification + client2.clearReceived(); + await client.call('disposeSession', { session: sessionUri }); + + const removed = await client2.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + assert.ok(removed, 'unsubscribed client should still receive sessionRemoved notification'); + + client2.close(); + }); + + test('late subscriber gets current state via snapshot', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-late-sub'); + dispatchTurnStarted(client, sessionUri, 'turn-late', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Client 2 joins after the turn has completed + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-late-sub-2' }); + + const result = await client2.call('subscribe', { resource: sessionUri }); + const state = result.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1, `late subscriber should see completed turn, got ${state.turns.length}`); + assert.strictEqual(state.turns[0].id, 'turn-late'); + assert.strictEqual(state.turns[0].state, 'complete'); + + client2.close(); + }); + + test('permission flow: client B confirms tool started by client A', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-cross-perm-1'); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-perm-2' }); + await client2.call('subscribe', { resource: sessionUri }); + client.clearReceived(); + client2.clearReceived(); + + // Client A starts the permission turn + dispatchTurnStarted(client, sessionUri, 'turn-cross-perm', 'permission', 1); + + // Both clients should see tool_start and tool_ready + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Client B confirms the tool call + client2.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-cross-perm', + toolCallId: 'tc-perm-1', + approved: true, + }, + }); + + // Both clients should see the response and turn completion + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client2.close(); + }); + + test('reconnect replays missed actions', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-reconnect'); + dispatchTurnStarted(client, sessionUri, 'turn-recon', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const allActions = client.receivedNotifications(n => n.method === 'action'); + assert.ok(allActions.length > 0); + const missedFromSeq = getActionEnvelope(allActions[0]).serverSeq - 1; + + client.close(); + + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + const result = await client2.call('reconnect', { + clientId: 'test-reconnect', + lastSeenServerSeq: missedFromSeq, + subscriptions: [sessionUri], + }); + + assert.ok(result.type === 'replay' || result.type === 'snapshot', 'should receive replay or snapshot'); + if (result.type === 'replay') { + assert.ok(result.actions.length > 0, 'should have replayed actions'); + } + + client2.close(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts new file mode 100644 index 0000000000000..7aeb7b2332360 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/sessionFeatures.integrationTest.ts @@ -0,0 +1,451 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ISubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { IResponsePartAction, ISessionAddedNotification, ITitleChangedAction } from '../../../common/state/sessionActions.js'; +import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; +import type { IListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; +import { PendingMessageKind, ResponsePartKind, type ISessionState } from '../../../common/state/sessionState.js'; +import { MOCK_AUTO_TITLE } from '../mockAgent.js'; +import { + createAndSubscribeSession, + dispatchTurnStarted, + getActionEnvelope, + isActionNotification, + IServerHandle, + nextSessionUri, + startServer, + TestProtocolClient, +} from './testHelpers.js'; + +suite('Protocol WebSocket — Session Features', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + // ---- Session rename / title ------------------------------------------------ + + test('client titleChanged updates session state snapshot', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-titleChanged'); + + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/titleChanged', + session: sessionUri, + title: 'My Custom Title', + }, + }); + + const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; + assert.strictEqual(titleAction.title, 'My Custom Title'); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.summary.title, 'My Custom Title'); + }); + + test('agent-generated titleChanged is broadcast', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-agent-title'); + dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1); + + const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; + assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE); + }); + + test('renamed session title persists across listSessions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-title-list'); + + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/titleChanged', + session: sessionUri, + title: 'Persisted Title', + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); + + // Poll listSessions until the persisted title appears (async DB write) + let session: { title: string } | undefined; + for (let i = 0; i < 20; i++) { + const result = await client.call('listSessions'); + session = result.items.find(s => s.resource === sessionUri); + if (session?.title === 'Persisted Title') { + break; + } + await timeout(100); + } + assert.ok(session, 'session should appear in listSessions'); + assert.strictEqual(session.title, 'Persisted Title'); + }); + + // ---- Reasoning events ------------------------------------------------------ + + test('reasoning events produce reasoning response parts and append actions', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-reasoning'); + dispatchTurnStarted(client, sessionUri, 'turn-reasoning', 'with-reasoning', 1); + + // The first reasoning event produces a responsePart with kind Reasoning + const reasoningPart = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/responsePart')) { + return false; + } + const action = getActionEnvelope(n).action as IResponsePartAction; + return action.part.kind === ResponsePartKind.Reasoning; + }); + const reasoningAction = getActionEnvelope(reasoningPart).action as IResponsePartAction; + assert.strictEqual(reasoningAction.part.kind, ResponsePartKind.Reasoning); + + // The second reasoning chunk produces a session/reasoning append action + const appendNotif = await client.waitForNotification(n => isActionNotification(n, 'session/reasoning')); + const appendAction = getActionEnvelope(appendNotif).action; + assert.strictEqual(appendAction.type, 'session/reasoning'); + if (appendAction.type === 'session/reasoning') { + assert.strictEqual(appendAction.content, ' about this...'); + } + + // Then the markdown response part + const mdPart = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/responsePart')) { + return false; + } + const action = getActionEnvelope(n).action as IResponsePartAction; + return action.part.kind === ResponsePartKind.Markdown; + }); + assert.ok(mdPart); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // ---- Queued messages ------------------------------------------------------- + + test('queued message is auto-consumed when session is idle', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-queue-idle'); + client.clearReceived(); + + // Queue a message when the session is idle — server should immediately consume it + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/pendingMessageSet', + session: sessionUri, + kind: PendingMessageKind.Queued, + id: 'q-1', + userMessage: { text: 'hello' }, + }, + }); + + // The server should auto-consume the queued message and start a turn + await client.waitForNotification(n => isActionNotification(n, 'session/turnStarted')); + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Verify the turn was created from the queued message + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1); + assert.strictEqual(state.turns[state.turns.length - 1].userMessage.text, 'hello'); + // Queue should be empty after consumption + assert.ok(!state.queuedMessages?.length, 'queued messages should be empty after consumption'); + }); + + test('queued message waits for in-progress turn to complete', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-queue-wait'); + + // Start a turn first + dispatchTurnStarted(client, sessionUri, 'turn-first', 'hello', 1); + + // Wait for the first turn's response to confirm it is in progress + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + + // Queue a message while the turn is in progress + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/pendingMessageSet', + session: sessionUri, + kind: PendingMessageKind.Queued, + id: 'q-wait-1', + userMessage: { text: 'hello' }, + }, + }); + + // First turn should complete + const firstComplete = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/turnComplete')) { + return false; + } + return (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-first'; + }); + const firstSeq = getActionEnvelope(firstComplete).serverSeq; + + // The queued message's turn should complete AFTER the first turn + const secondComplete = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/turnComplete')) { + return false; + } + const envelope = getActionEnvelope(n); + return (envelope.action as { turnId: string }).turnId !== 'turn-first' + && envelope.serverSeq > firstSeq; + }); + assert.ok(secondComplete, 'should receive a second turnComplete from the queued message'); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); + }); + + // ---- Steering messages ---------------------------------------------------- + + test('steering message is set and consumed by agent', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-steering'); + + // Start a turn first + dispatchTurnStarted(client, sessionUri, 'turn-steer', 'hello', 1); + + // Set a steering message while the turn is in progress + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/pendingMessageSet', + session: sessionUri, + kind: PendingMessageKind.Steering, + id: 'steer-1', + userMessage: { text: 'Please be concise' }, + }, + }); + + // The steering message should be set in state initially + const setNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageSet')); + assert.ok(setNotif, 'should see pendingMessageSet action'); + + // The mock agent consumes steering and fires steering_consumed, + // which causes the server to dispatch pendingMessageRemoved + const removedNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageRemoved')); + assert.ok(removedNotif, 'should see pendingMessageRemoved after agent consumes steering'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Steering should be cleared from state + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(!state.steeringMessage, 'steering message should be cleared after consumption'); + }); + + // ---- Truncation ----------------------------------------------------------- + + test('truncate session removes turns after specified turn', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-truncate'); + + // Create two turns + dispatchTurnStarted(client, sessionUri, 'turn-t1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t1'); + + client.clearReceived(); + dispatchTurnStarted(client, sessionUri, 'turn-t2', 'hello', 2); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2'); + + // Verify 2 turns exist + let snapshot = await client.call('subscribe', { resource: sessionUri }); + let state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 2); + + client.clearReceived(); + + // Truncate: keep only turn-t1 + client.notify('dispatchAction', { + clientSeq: 3, + action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-t1' }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); + + snapshot = await client.call('subscribe', { resource: sessionUri }); + state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 1); + assert.strictEqual(state.turns[0].id, 'turn-t1'); + }); + + test('truncate all turns clears session history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-truncate-all'); + + dispatchTurnStarted(client, sessionUri, 'turn-ta1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + client.clearReceived(); + + // Truncate all (no turnId) + client.notify('dispatchAction', { + clientSeq: 2, + action: { type: 'session/truncated', session: sessionUri }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 0); + }); + + test('new turn after truncation works correctly', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-truncate-resume'); + + dispatchTurnStarted(client, sessionUri, 'turn-tr1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr1'); + + client.clearReceived(); + dispatchTurnStarted(client, sessionUri, 'turn-tr2', 'hello', 2); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr2'); + + client.clearReceived(); + + // Truncate to turn-tr1 + client.notify('dispatchAction', { + clientSeq: 3, + action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-tr1' }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); + + // Send a new turn after truncation + dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.turns.length, 2); + assert.strictEqual(state.turns[0].id, 'turn-tr1'); + assert.strictEqual(state.turns[1].id, 'turn-tr3'); + }); + + // ---- Fork ----------------------------------------------------------------- + + test('fork creates a new session with source history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-fork'); + + // Create two turns + dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1'); + + client.clearReceived(); + dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2'); + + client.clearReceived(); + + // Fork at turn-f1 (keep turns up to and including turn-f1) + const forkedSessionUri = nextSessionUri(); + await client.call('createSession', { + session: forkedSessionUri, + provider: 'mock', + fork: { session: sessionUri, turnId: 'turn-f1' }, + }); + + const addedNotif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + + // Subscribe — forked session should have 1 turn + const snapshot = await client.call('subscribe', { resource: addedSession.summary.resource }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.lifecycle, 'ready'); + assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn'); + + // Source session should be unaffected + const sourceSnapshot = await client.call('subscribe', { resource: sessionUri }); + const sourceState = sourceSnapshot.snapshot.state as ISessionState; + assert.strictEqual(sourceState.turns.length, 2); + }); + + test('fork with invalid turn ID returns error', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid'); + + let gotError = false; + try { + await client.call('createSession', { + session: nextSessionUri(), + provider: 'mock', + fork: { session: sessionUri, turnId: 'nonexistent-turn' }, + }); + } catch { + gotError = true; + } + assert.ok(gotError, 'should get error for invalid fork turn ID'); + }); + + test('fork with invalid source session returns error', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-fork-no-source' }); + + let gotError = false; + try { + await client.call('createSession', { + session: nextSessionUri(), + provider: 'mock', + fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' }, + }); + } catch { + gotError = true; + } + assert.ok(gotError, 'should get error for invalid fork source session'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts new file mode 100644 index 0000000000000..a11b39df6ee4e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/sessionLifecycle.integrationTest.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { ISessionAddedNotification, ISessionRemovedNotification } from '../../../common/state/sessionActions.js'; +import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; +import type { IListSessionsResult, INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js'; +import { ResponsePartKind, type IMarkdownResponsePart, type ISessionState, type IToolCallResponsePart } from '../../../common/state/sessionState.js'; +import { PRE_EXISTING_SESSION_URI } from '../mockAgent.js'; +import { + createAndSubscribeSession, + isActionNotification, + IServerHandle, + nextSessionUri, + startServer, + TestProtocolClient +} from './testHelpers.js'; + +suite('Protocol WebSocket — Session Lifecycle', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + test('create session triggers sessionAdded notification', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-create-session' }); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; + assert.strictEqual(URI.parse(notification.summary.resource).scheme, 'mock'); + assert.strictEqual(notification.summary.provider, 'mock'); + }); + + test('listSessions returns sessions', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-list-sessions' }); + + await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); + await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + + const result = await client.call('listSessions'); + assert.ok(Array.isArray(result.items)); + assert.ok(result.items.length >= 1, 'should have at least one session'); + }); + + test('dispose session sends sessionRemoved notification', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-dispose'); + await client.call('disposeSession', { session: sessionUri }); + + const notif = await client.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' + ); + const removed = (notif.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; + assert.strictEqual(removed.session.toString(), sessionUri.toString()); + }); + + test('subscribe to a pre-existing session restores turns from agent history', async function () { + this.timeout(10_000); + + await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-restore' }); + + // The mock agent seeds a pre-existing session that was never created + // through the server's handleCreateSession -- simulating a session + // from a previous server lifetime. + const preExistingUri = PRE_EXISTING_SESSION_URI.toString(); + const list = await client.call('listSessions'); + const preExisting = list.items.find(s => s.resource === preExistingUri); + assert.ok(preExisting, 'listSessions should include the pre-existing session'); + + // Clear notifications so we can verify no duplicate sessionAdded fires. + client.clearReceived(); + + // Subscribing to this session should trigger the restore path: the + // server fetches message history from the agent and reconstructs turns. + const result = await client.call('subscribe', { resource: preExistingUri }); + const state = result.snapshot.state as ISessionState; + + assert.strictEqual(state.lifecycle, 'ready', 'restored session should be in ready state'); + assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`); + + const turn = state.turns[0]; + assert.strictEqual(turn.userMessage.text, 'What files are here?'); + assert.strictEqual(turn.state, 'complete'); + const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); + assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts'); + assert.strictEqual(toolCallParts[0].toolCall.toolName, 'list_files'); + const mdParts = turn.responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); + assert.ok(mdParts.some(p => p.content.includes('file1.ts')), 'turn should have markdown part mentioning file1.ts'); + + // Restoring should NOT emit a duplicate sessionAdded notification + // (the session is already known to clients via listSessions). + await new Promise(resolve => setTimeout(resolve, 200)); + const sessionAddedNotifs = client.receivedNotifications(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + assert.strictEqual(sessionAddedNotifs.length, 0, 'restore should not emit sessionAdded'); + }); + + test('isRead and isDone flags survive in listSessions after dispatch', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-read-done-flags'); + + // Dispatch isDone=true + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/isDoneChanged', + session: sessionUri, + isDone: true, + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/isDoneChanged')); + + // Dispatch isRead=true + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/isReadChanged', + session: sessionUri, + isRead: true, + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged')); + + // Verify the flags are reflected in the subscribed session state + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.strictEqual(state.summary.isDone, true, 'isDone should be true in snapshot'); + assert.strictEqual(state.summary.isRead, true, 'isRead should be true in snapshot'); + + // Poll listSessions until the persisted flags appear (async DB write) + client.close(); + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-read-done-flags-2' }); + + let session: IListSessionsResult['items'][0] | undefined; + for (let i = 0; i < 20; i++) { + const result = await client2.call('listSessions'); + session = result.items.find(s => s.resource === sessionUri); + if (session?.isDone === true && session?.isRead === true) { + break; + } + await timeout(100); + } + assert.ok(session, 'session should appear in listSessions'); + assert.strictEqual(session.isDone, true, 'isDone should be persisted in listSessions'); + assert.strictEqual(session.isRead, true, 'isRead should be persisted in listSessions'); + + client2.close(); + }); + + test('dispatching isRead=false explicitly persists as false', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-isread-false'); + + // On a fresh session, isRead is undefined in the DB. Dispatching + // isRead=false should persist the value so that listSessions + // returns an explicit `false` rather than omitting the field. + client.notify('dispatchAction', { + clientSeq: 1, + action: { + type: 'session/isReadChanged', + session: sessionUri, + isRead: false, + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/isReadChanged')); + + client.close(); + const client2 = new TestProtocolClient(server.port); + await client2.connect(); + await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-isread-false-2' }); + + let session: IListSessionsResult['items'][0] | undefined; + for (let i = 0; i < 20; i++) { + const result = await client2.call('listSessions'); + session = result.items.find(s => s.resource === sessionUri); + if (session && session.isRead === false) { + break; + } + await timeout(100); + } + assert.ok(session, 'session should appear in listSessions'); + assert.strictEqual(session.isRead, false, 'isRead=false should be explicitly persisted'); + + client2.close(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts new file mode 100644 index 0000000000000..089ba2391fcb4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChildProcess, fork } from 'child_process'; +import { fileURLToPath } from 'url'; +import { WebSocket } from 'ws'; +import { URI } from '../../../../../base/common/uri.js'; +import { ISubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { IActionEnvelope, ISessionAddedNotification } from '../../../common/state/sessionActions.js'; +import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js'; +import { + isJsonRpcNotification, + isJsonRpcResponse, + type IAhpNotification, + type IJsonRpcErrorResponse, + type IJsonRpcSuccessResponse, + type INotificationBroadcastParams, + type IProtocolMessage, +} from '../../../common/state/sessionProtocol.js'; + +// ---- JSON-RPC test client --------------------------------------------------- + +interface IPendingCall { + resolve: (result: unknown) => void; + reject: (err: Error) => void; +} + +export class TestProtocolClient { + private readonly _ws: WebSocket; + private _nextId = 1; + private readonly _pendingCalls = new Map(); + private readonly _notifications: IAhpNotification[] = []; + private readonly _notifWaiters: { predicate: (n: IAhpNotification) => boolean; resolve: (n: IAhpNotification) => void; reject: (err: Error) => void }[] = []; + + constructor(port: number) { + this._ws = new WebSocket(`ws://127.0.0.1:${port}`); + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this._ws.on('open', () => { + this._ws.on('message', (data: Buffer | string) => { + const text = typeof data === 'string' ? data : data.toString('utf-8'); + const msg = JSON.parse(text); + this._handleMessage(msg); + }); + resolve(); + }); + this._ws.on('error', reject); + }); + } + + private _handleMessage(msg: IProtocolMessage): void { + if (isJsonRpcResponse(msg)) { + const pending = this._pendingCalls.get(msg.id); + if (pending) { + this._pendingCalls.delete(msg.id); + const errResp = msg as IJsonRpcErrorResponse; + if (errResp.error) { + pending.reject(new Error(errResp.error.message)); + } else { + pending.resolve((msg as IJsonRpcSuccessResponse).result); + } + } + } else if (isJsonRpcNotification(msg)) { + const notif = msg; + for (let i = this._notifWaiters.length - 1; i >= 0; i--) { + if (this._notifWaiters[i].predicate(notif)) { + const waiter = this._notifWaiters.splice(i, 1)[0]; + waiter.resolve(notif); + } + } + this._notifications.push(notif); + } + } + + /** Send a JSON-RPC notification (fire-and-forget). */ + notify(method: string, params?: unknown): void { + this._ws.send(JSON.stringify({ jsonrpc: '2.0', method, params })); + } + + /** Send a JSON-RPC request and await the response. */ + call(method: string, params?: unknown, timeoutMs = 5000): Promise { + const id = this._nextId++; + this._ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pendingCalls.delete(id); + reject(new Error(`Timeout waiting for response to ${method} (id=${id}, ${timeoutMs}ms)`)); + }, timeoutMs); + + this._pendingCalls.set(id, { + resolve: result => { clearTimeout(timer); resolve(result as T); }, + reject: err => { clearTimeout(timer); reject(err); }, + }); + }); + } + + /** Wait for a server notification matching a predicate. */ + waitForNotification(predicate: (n: IAhpNotification) => boolean, timeoutMs = 5000): Promise { + const existing = this._notifications.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); + if (idx >= 0) { + this._notifWaiters.splice(idx, 1); + } + reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`)); + }, timeoutMs); + + this._notifWaiters.push({ + predicate, + resolve: n => { clearTimeout(timer); resolve(n); }, + reject, + }); + }); + } + + /** Return all received notifications matching a predicate. */ + receivedNotifications(predicate?: (n: IAhpNotification) => boolean): IAhpNotification[] { + return predicate ? this._notifications.filter(predicate) : [...this._notifications]; + } + + /** Send a raw string over the WebSocket without JSON serialization. */ + sendRaw(data: string): void { + this._ws.send(data); + } + + /** Wait for the next raw message from the server. */ + waitForRawMessage(timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + reject(new Error(`Timeout waiting for raw message (${timeoutMs}ms)`)); + }, timeoutMs); + const onMsg = (data: Buffer | string) => { + cleanup(); + const text = typeof data === 'string' ? data : data.toString('utf-8'); + resolve(JSON.parse(text)); + }; + const cleanup = () => { + clearTimeout(timer); + this._ws.removeListener('message', onMsg); + }; + this._ws.on('message', onMsg); + }); + } + + close(): void { + for (const w of this._notifWaiters) { + w.reject(new Error('Client closed')); + } + this._notifWaiters.length = 0; + for (const [, p] of this._pendingCalls) { + p.reject(new Error('Client closed')); + } + this._pendingCalls.clear(); + this._ws.close(); + } + + clearReceived(): void { + this._notifications.length = 0; + } +} + +// ---- Server process lifecycle ----------------------------------------------- + +export interface IServerHandle { + process: ChildProcess; + port: number; +} + +export async function startServer(): Promise { + return new Promise((resolve, reject) => { + const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url)); + const child = fork(serverPath, ['--enable-mock-agent', '--quiet', '--port', '0', '--without-connection-token'], { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + }); + + const timer = setTimeout(() => { + child.kill(); + reject(new Error('Server startup timed out')); + }, 10_000); + + child.stdout!.on('data', (data: Buffer) => { + const text = data.toString(); + const match = text.match(/READY:(\d+)/); + if (match) { + clearTimeout(timer); + resolve({ process: child, port: parseInt(match[1], 10) }); + } + }); + + child.stderr!.on('data', () => { + // Intentionally swallowed - the test runner fails if console.error is used. + }); + + child.on('error', err => { + clearTimeout(timer); + reject(err); + }); + + child.on('exit', code => { + clearTimeout(timer); + reject(new Error(`Server exited prematurely with code ${code}`)); + }); + }); +} + +// ---- Helpers ---------------------------------------------------------------- + +let sessionCounter = 0; + +export function nextSessionUri(): string { + return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }).toString(); +} + +export function isActionNotification(n: IAhpNotification, actionType: string): boolean { + if (n.method !== 'action') { + return false; + } + const envelope = n.params as unknown as IActionEnvelope; + return envelope.action.type === actionType; +} + +export function getActionEnvelope(n: IAhpNotification): IActionEnvelope { + return n.params as unknown as IActionEnvelope; +} + +/** Perform handshake, create a session, subscribe, and return its URI. */ +export async function createAndSubscribeSession(c: TestProtocolClient, clientId: string, workingDirectory?: string): Promise { + await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId }); + + await c.call('createSession', { session: nextSessionUri(), provider: 'mock', workingDirectory }); + + const notif = await c.waitForNotification(n => + n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' + ); + const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; + + await c.call('subscribe', { resource: realSessionUri }); + c.clearReceived(); + + return realSessionUri; +} + +export function dispatchTurnStarted(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { + c.notify('dispatchAction', { + clientSeq, + action: { + type: 'session/turnStarted', + session, + turnId, + userMessage: { text }, + }, + }); +} diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts new file mode 100644 index 0000000000000..1485517adc71c --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/toolApproval.integrationTest.ts @@ -0,0 +1,188 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IResponsePartAction } from '../../../common/state/sessionActions.js'; +import { ResponsePartKind, type IMarkdownResponsePart } from '../../../common/state/sessionState.js'; +import { + createAndSubscribeSession, + dispatchTurnStarted, + getActionEnvelope, + IServerHandle, + isActionNotification, + startServer, + TestProtocolClient, +} from './testHelpers.js'; + +suite('Protocol WebSocket — Permissions & Auto-Approve', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + // ---- Manual permission flow ------------------------------------------------ + + test('permission request → resolve → response', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-permission'); + dispatchTurnStarted(client, sessionUri, 'turn-perm', 'permission', 1); + + // The mock agent fires tool_start + tool_ready instead of permission_request + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Confirm the tool call + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-perm', + toolCallId: 'tc-perm-1', + approved: true, + }, + }); + + const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; + assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); + assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Allowed.'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // ---- Edit auto-approve patterns ------------------------------------------- + + test('auto-approves write to regular file (no pending confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', 'file:///workspace'); + client.clearReceived(); + + // Start a turn that triggers a write permission request for a regular .ts file + dispatchTurnStarted(client, sessionUri, 'turn-autoapprove', 'write-file', 1); + + // The write should be auto-approved — we should see tool_start, tool_complete, and turn_complete + // but NOT a pending-confirmation toolCallReady (one without `confirmed`). + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Verify no pending-confirmation toolCallReady was received + const pendingConfirmNotifs = client.receivedNotifications(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + const action = getActionEnvelope(n).action as { confirmed?: string }; + return !action.confirmed; + }); + assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for auto-approved write'); + }); + + test('blocks write to .env file (requires manual confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', 'file:///workspace'); + client.clearReceived(); + + // Start a turn that tries to write .env (blocked by default patterns) + dispatchTurnStarted(client, sessionUri, 'turn-deny', 'write-env', 1); + + // The .env write should NOT be auto-approved — we should see toolCallReady (pending confirmation) + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Confirm it manually to let the turn complete + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-deny', + toolCallId: 'tc-write-env-1', + approved: true, + confirmed: 'user-action', + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + // ---- Shell auto-approve --------------------------------------------------- + + test('auto-approves allowed shell command (no pending confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-shell-approve'); + client.clearReceived(); + + // Start a turn that triggers a shell permission request for "ls -la" (allowed command) + dispatchTurnStarted(client, sessionUri, 'turn-shell-approve', 'run-safe-command', 1); + + // The shell command should be auto-approved — we should see tool_start, tool_complete, and turn_complete + // but NOT a pending-confirmation toolCallReady. + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + // Verify no pending-confirmation toolCallReady was received + const pendingConfirmNotifs = client.receivedNotifications(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + const action = getActionEnvelope(n).action as { confirmed?: string }; + return !action.confirmed; + }); + assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for allowed shell command'); + }); + + test('blocks denied shell command (requires manual confirmation)', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-shell-deny'); + client.clearReceived(); + + // Start a turn that triggers a shell permission request for "rm -rf /" (denied command) + dispatchTurnStarted(client, sessionUri, 'turn-shell-deny', 'run-dangerous-command', 1); + + // The denied command should NOT be auto-approved — we should see toolCallReady (pending confirmation) + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + + // Confirm it manually to let the turn complete + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-shell-deny', + toolCallId: 'tc-shell-deny-1', + approved: true, + confirmed: 'user-action', + }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts new file mode 100644 index 0000000000000..ce67a79907a8a --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -0,0 +1,183 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ISubscribeResult } from '../../../common/state/protocol/commands.js'; +import type { IResponsePartAction } from '../../../common/state/sessionActions.js'; +import type { IFetchTurnsResult } from '../../../common/state/sessionProtocol.js'; +import { ResponsePartKind, type IMarkdownResponsePart, type ISessionState } from '../../../common/state/sessionState.js'; +import { + createAndSubscribeSession, + dispatchTurnStarted, + getActionEnvelope, + IServerHandle, + isActionNotification, + startServer, + TestProtocolClient, +} from './testHelpers.js'; + +suite('Protocol WebSocket — Turn Execution', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + + suiteSetup(async function () { + this.timeout(15_000); + server = await startServer(); + }); + + suiteTeardown(function () { + server.process.kill(); + }); + + setup(async function () { + this.timeout(10_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(function () { + client.close(); + }); + + test('send message and receive responsePart + turnComplete', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-send-message'); + dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1); + + const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; + assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); + assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Hello, world!'); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + test('tool invocation: toolCallStart → toolCallComplete → responsePart → turnComplete', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation'); + dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1); + + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); + await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); + const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); + const tcAction = getActionEnvelope(toolComplete).action; + if (tcAction.type === 'session/toolCallComplete') { + assert.strictEqual(tcAction.result.success, true); + } + await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + }); + + test('error prompt triggers session/error', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-error'); + dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1); + + const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error')); + const errorAction = getActionEnvelope(errorNotif).action; + if (errorAction.type === 'session/error') { + assert.strictEqual(errorAction.error.message, 'Something went wrong'); + } + }); + + test('cancel turn stops in-progress processing', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-cancel'); + dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1); + + client.notify('dispatchAction', { + clientSeq: 2, + action: { type: 'session/turnCancelled', session: sessionUri, turnId: 'turn-cancel' }, + }); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1); + assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled'); + }); + + test('multiple sequential turns accumulate in history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-multi-turns'); + + dispatchTurnStarted(client, sessionUri, 'turn-m1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + dispatchTurnStarted(client, sessionUri, 'turn-m2', 'hello', 2); + await new Promise(resolve => setTimeout(resolve, 200)); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); + assert.strictEqual(state.turns[0].id, 'turn-m1'); + assert.strictEqual(state.turns[1].id, 'turn-m2'); + }); + + test('fetchTurns returns completed turn history', async function () { + this.timeout(15_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns'); + + dispatchTurnStarted(client, sessionUri, 'turn-ft-1', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + dispatchTurnStarted(client, sessionUri, 'turn-ft-2', 'hello', 2); + await new Promise(resolve => setTimeout(resolve, 200)); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const result = await client.call('fetchTurns', { session: sessionUri, limit: 10 }); + assert.ok(result.turns.length >= 2); + assert.strictEqual(typeof result.hasMore, 'boolean'); + }); + + test('usage info is captured on completed turn', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-usage'); + dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1); + + const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage')); + const usageAction = getActionEnvelope(usageNotif).action as { type: string; usage: { inputTokens: number; outputTokens: number } }; + assert.strictEqual(usageAction.usage.inputTokens, 100); + assert.strictEqual(usageAction.usage.outputTokens, 50); + + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const snapshot = await client.call('subscribe', { resource: sessionUri }); + const state = snapshot.snapshot.state as ISessionState; + assert.ok(state.turns.length >= 1); + const turn = state.turns[state.turns.length - 1]; + assert.ok(turn.usage); + assert.strictEqual(turn.usage!.inputTokens, 100); + assert.strictEqual(turn.usage!.outputTokens, 50); + }); + + test('modifiedAt updates on turn completion', async function () { + this.timeout(10_000); + + const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt'); + + const initialSnapshot = await client.call('subscribe', { resource: sessionUri }); + const initialModifiedAt = (initialSnapshot.snapshot.state as ISessionState).summary.modifiedAt; + + await new Promise(resolve => setTimeout(resolve, 50)); + + dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1); + await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); + + const updatedSnapshot = await client.call('subscribe', { resource: sessionUri }); + const updatedModifiedAt = (updatedSnapshot.snapshot.state as ISessionState).summary.modifiedAt; + assert.ok(updatedModifiedAt >= initialModifiedAt); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 64a77cd26b53c..17477684a3f43 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -9,7 +9,7 @@ import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; -import type { IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../../common/agentService.js'; +import type { IAgentCreateSessionConfig, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult } from '../../common/agentService.js'; import { IResourceReadResult } from '../../common/state/protocol/commands.js'; import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; @@ -101,10 +101,7 @@ class MockAgentService implements IAgentService { } unsubscribe(_resource: URI): void { } async shutdown(): Promise { } - async getResourceMetadata(): Promise { return { resources: [] }; } async authenticate(_params: IAuthenticateParams): Promise { return { authenticated: true }; } - async refreshModels(): Promise { } - async listAgents(): Promise { return []; } async resourceWrite(_params: IResourceWriteParams): Promise { return {}; } async resourceList(uri: URI): Promise { this.browsedUris.push(uri); @@ -427,28 +424,16 @@ suite('ProtocolServerHandler', () => { // ---- Extension methods: auth ---------------------------------------- - test('getResourceMetadata returns resource metadata via extension request', async () => { - const transport = connectClient('client-metadata'); - transport.sent.length = 0; - - const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'getResourceMetadata')); - const resp = await responsePromise as { result?: { resources: unknown[] } }; - - assert.ok(resp?.result); - assert.ok(Array.isArray(resp.result!.resources)); - }); - - test('authenticate returns result via extension request', async () => { + test('authenticate returns result via typed request', async () => { const transport = connectClient('client-auth'); transport.sent.length = 0; const responsePromise = waitForResponse(transport, 2); transport.simulateMessage(request(2, 'authenticate', { resource: 'https://api.github.com', token: 'test-token' })); - const resp = await responsePromise as { result?: { authenticated: boolean } }; + const resp = await responsePromise as { result?: Record; error?: { code: number; message: string } }; - assert.ok(resp?.result); - assert.strictEqual(resp.result!.authenticated, true); + assert.ok(!resp.error, `unexpected error: ${resp.error?.message}`); + assert.deepStrictEqual(resp.result, {}); }); test('extension request preserves ProtocolError code and data', async () => { diff --git a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts deleted file mode 100644 index 83306c71f0e0c..0000000000000 --- a/src/vs/platform/agentHost/test/node/protocolWebSocket.integrationTest.ts +++ /dev/null @@ -1,1444 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { ChildProcess, fork } from 'child_process'; -import { fileURLToPath } from 'url'; -import { WebSocket } from 'ws'; -import { timeout } from '../../../../base/common/async.js'; -import { URI } from '../../../../base/common/uri.js'; -import { ISubscribeResult } from '../../common/state/protocol/commands.js'; -import type { IActionEnvelope, IResponsePartAction, ISessionAddedNotification, ISessionRemovedNotification, ITitleChangedAction, IUsageAction } from '../../common/state/sessionActions.js'; -import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; -import { - isJsonRpcNotification, - isJsonRpcResponse, - JSON_RPC_PARSE_ERROR, - type IAhpNotification, - type IFetchTurnsResult, - type IInitializeResult, - type IJsonRpcErrorResponse, - type IJsonRpcSuccessResponse, - type IListSessionsResult, - type INotificationBroadcastParams, - type IProtocolMessage, - type IReconnectResult -} from '../../common/state/sessionProtocol.js'; -import { PendingMessageKind, ResponsePartKind, type IMarkdownResponsePart, type ISessionState, type IToolCallResponsePart } from '../../common/state/sessionState.js'; -import { MOCK_AUTO_TITLE, PRE_EXISTING_SESSION_URI } from './mockAgent.js'; - -// ---- JSON-RPC test client --------------------------------------------------- - -interface IPendingCall { - resolve: (result: unknown) => void; - reject: (err: Error) => void; -} - -class TestProtocolClient { - private readonly _ws: WebSocket; - private _nextId = 1; - private readonly _pendingCalls = new Map(); - private readonly _notifications: IAhpNotification[] = []; - private readonly _notifWaiters: { predicate: (n: IAhpNotification) => boolean; resolve: (n: IAhpNotification) => void; reject: (err: Error) => void }[] = []; - - constructor(port: number) { - this._ws = new WebSocket(`ws://127.0.0.1:${port}`); - } - - async connect(): Promise { - return new Promise((resolve, reject) => { - this._ws.on('open', () => { - this._ws.on('message', (data: Buffer | string) => { - const text = typeof data === 'string' ? data : data.toString('utf-8'); - const msg = JSON.parse(text); - this._handleMessage(msg); - }); - resolve(); - }); - this._ws.on('error', reject); - }); - } - - private _handleMessage(msg: IProtocolMessage): void { - if (isJsonRpcResponse(msg)) { - // JSON-RPC response — resolve pending call - const pending = this._pendingCalls.get(msg.id); - if (pending) { - this._pendingCalls.delete(msg.id); - const errResp = msg as IJsonRpcErrorResponse; - if (errResp.error) { - pending.reject(new Error(errResp.error.message)); - } else { - pending.resolve((msg as IJsonRpcSuccessResponse).result); - } - } - } else if (isJsonRpcNotification(msg)) { - // JSON-RPC notification from server - const notif = msg; - // Check waiters first - for (let i = this._notifWaiters.length - 1; i >= 0; i--) { - if (this._notifWaiters[i].predicate(notif)) { - const waiter = this._notifWaiters.splice(i, 1)[0]; - waiter.resolve(notif); - } - } - this._notifications.push(notif); - } - } - - /** Send a JSON-RPC notification (fire-and-forget). */ - notify(method: string, params?: unknown): void { - this._ws.send(JSON.stringify({ jsonrpc: '2.0', method, params })); - } - - /** Send a JSON-RPC request and await the response. */ - call(method: string, params?: unknown, timeoutMs = 5000): Promise { - const id = this._nextId++; - this._ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params })); - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this._pendingCalls.delete(id); - reject(new Error(`Timeout waiting for response to ${method} (id=${id}, ${timeoutMs}ms)`)); - }, timeoutMs); - - this._pendingCalls.set(id, { - resolve: result => { clearTimeout(timer); resolve(result as T); }, - reject: err => { clearTimeout(timer); reject(err); }, - }); - }); - } - - /** Wait for a server notification matching a predicate. */ - waitForNotification(predicate: (n: IAhpNotification) => boolean, timeoutMs = 5000): Promise { - const existing = this._notifications.find(predicate); - if (existing) { - return Promise.resolve(existing); - } - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - const idx = this._notifWaiters.findIndex(w => w.resolve === resolve); - if (idx >= 0) { - this._notifWaiters.splice(idx, 1); - } - reject(new Error(`Timeout waiting for notification (${timeoutMs}ms)`)); - }, timeoutMs); - - this._notifWaiters.push({ - predicate, - resolve: n => { clearTimeout(timer); resolve(n); }, - reject, - }); - }); - } - - /** Return all received notifications matching a predicate. */ - receivedNotifications(predicate?: (n: IAhpNotification) => boolean): IAhpNotification[] { - return predicate ? this._notifications.filter(predicate) : [...this._notifications]; - } - - /** Send a raw string over the WebSocket without JSON serialization. */ - sendRaw(data: string): void { - this._ws.send(data); - } - - /** Wait for the next raw message from the server. */ - waitForRawMessage(timeoutMs = 5000): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - cleanup(); - reject(new Error(`Timeout waiting for raw message (${timeoutMs}ms)`)); - }, timeoutMs); - const onMsg = (data: Buffer | string) => { - cleanup(); - const text = typeof data === 'string' ? data : data.toString('utf-8'); - resolve(JSON.parse(text)); - }; - const cleanup = () => { - clearTimeout(timer); - this._ws.removeListener('message', onMsg); - }; - this._ws.on('message', onMsg); - }); - } - - close(): void { - for (const w of this._notifWaiters) { - w.reject(new Error('Client closed')); - } - this._notifWaiters.length = 0; - for (const [, p] of this._pendingCalls) { - p.reject(new Error('Client closed')); - } - this._pendingCalls.clear(); - this._ws.close(); - } - - clearReceived(): void { - this._notifications.length = 0; - } -} - -// ---- Server process lifecycle ----------------------------------------------- - -async function startServer(): Promise<{ process: ChildProcess; port: number }> { - return new Promise((resolve, reject) => { - const serverPath = fileURLToPath(new URL('../../node/agentHostServerMain.js', import.meta.url)); - const child = fork(serverPath, ['--enable-mock-agent', '--quiet', '--port', '0', '--without-connection-token'], { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], - }); - - const timeout = setTimeout(() => { - child.kill(); - reject(new Error('Server startup timed out')); - }, 10_000); - - child.stdout!.on('data', (data: Buffer) => { - const text = data.toString(); - const match = text.match(/READY:(\d+)/); - if (match) { - clearTimeout(timeout); - resolve({ process: child, port: parseInt(match[1], 10) }); - } - }); - - child.stderr!.on('data', () => { - // Intentionally swallowed - the test runner fails if console.error is used. - }); - - child.on('error', err => { - clearTimeout(timeout); - reject(err); - }); - - child.on('exit', code => { - clearTimeout(timeout); - reject(new Error(`Server exited prematurely with code ${code}`)); - }); - }); -} - -// ---- Helpers ---------------------------------------------------------------- - -let sessionCounter = 0; - -function nextSessionUri(): string { - return URI.from({ scheme: 'mock', path: `/test-session-${++sessionCounter}` }).toString(); -} - -function isActionNotification(n: IAhpNotification, actionType: string): boolean { - if (n.method !== 'action') { - return false; - } - const envelope = n.params as unknown as IActionEnvelope; - return envelope.action.type === actionType; -} - -function getActionEnvelope(n: IAhpNotification): IActionEnvelope { - return n.params as unknown as IActionEnvelope; -} - -/** Perform handshake, create a session, subscribe, and return its URI. */ -async function createAndSubscribeSession(c: TestProtocolClient, clientId: string, workingDirectory?: string): Promise { - await c.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId }); - - await c.call('createSession', { session: nextSessionUri(), provider: 'mock', workingDirectory }); - - const notif = await c.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const realSessionUri = ((notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; - - await c.call('subscribe', { resource: realSessionUri }); - c.clearReceived(); - - return realSessionUri; -} - -function dispatchTurnStarted(c: TestProtocolClient, session: string, turnId: string, text: string, clientSeq: number): void { - c.notify('dispatchAction', { - clientSeq, - action: { - type: 'session/turnStarted', - session, - turnId, - userMessage: { text }, - }, - }); -} - -// ---- Test suite ------------------------------------------------------------- - -suite('Protocol WebSocket E2E', function () { - - let server: { process: ChildProcess; port: number }; - let client: TestProtocolClient; - - suiteSetup(async function () { - this.timeout(15_000); - server = await startServer(); - }); - - suiteTeardown(function () { - server.process.kill(); - }); - - setup(async function () { - this.timeout(10_000); - client = new TestProtocolClient(server.port); - await client.connect(); - }); - - teardown(function () { - client.close(); - }); - - // 1. Handshake - test('handshake returns initialize response with protocol version', async function () { - this.timeout(5_000); - - const result = await client.call('initialize', { - protocolVersion: PROTOCOL_VERSION, - clientId: 'test-handshake', - initialSubscriptions: [URI.from({ scheme: 'agenthost', path: '/root' }).toString()], - }); - - assert.strictEqual(result.protocolVersion, PROTOCOL_VERSION); - assert.ok(result.serverSeq >= 0); - assert.ok(result.snapshots.length >= 1, 'should have root state snapshot'); - }); - - // 2. Create session - test('create session triggers sessionAdded notification', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-create-session' }); - - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const notification = (notif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; - assert.strictEqual(URI.parse(notification.summary.resource).scheme, 'mock'); - assert.strictEqual(notification.summary.provider, 'mock'); - }); - - // 3. Send message and receive response - test('send message and receive responsePart + turnComplete', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-send-message'); - dispatchTurnStarted(client, sessionUri, 'turn-1', 'hello', 1); - - const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; - assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); - assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Hello, world!'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // 4. Tool invocation lifecycle - test('tool invocation: toolCallStart → toolCallComplete → responsePart → turnComplete', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-tool-invocation'); - dispatchTurnStarted(client, sessionUri, 'turn-tool', 'use-tool', 1); - - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - const toolComplete = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - const tcAction = getActionEnvelope(toolComplete).action; - if (tcAction.type === 'session/toolCallComplete') { - assert.strictEqual(tcAction.result.success, true); - } - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // 5. Error handling - test('error prompt triggers session/error', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-error'); - dispatchTurnStarted(client, sessionUri, 'turn-err', 'error', 1); - - const errorNotif = await client.waitForNotification(n => isActionNotification(n, 'session/error')); - const errorAction = getActionEnvelope(errorNotif).action; - if (errorAction.type === 'session/error') { - assert.strictEqual(errorAction.error.message, 'Something went wrong'); - } - }); - - // 6. Permission flow (via tool_ready confirmation) - test('permission request → resolve → response', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-permission'); - dispatchTurnStarted(client, sessionUri, 'turn-perm', 'permission', 1); - - // The mock agent now fires tool_start + tool_ready instead of permission_request - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Confirm the tool call - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-perm', - toolCallId: 'tc-perm-1', - approved: true, - }, - }); - - const responsePart = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const responsePartAction = getActionEnvelope(responsePart).action as IResponsePartAction; - assert.strictEqual(responsePartAction.part.kind, ResponsePartKind.Markdown); - assert.strictEqual((responsePartAction.part as IMarkdownResponsePart).content, 'Allowed.'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // 7. Session list - test('listSessions returns sessions', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-list-sessions' }); - - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - - const result = await client.call('listSessions'); - assert.ok(Array.isArray(result.items)); - assert.ok(result.items.length >= 1, 'should have at least one session'); - }); - - // 8. Reconnect - test('reconnect replays missed actions', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-reconnect'); - dispatchTurnStarted(client, sessionUri, 'turn-recon', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const allActions = client.receivedNotifications(n => n.method === 'action'); - assert.ok(allActions.length > 0); - const missedFromSeq = getActionEnvelope(allActions[0]).serverSeq - 1; - - client.close(); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - const result = await client2.call('reconnect', { - clientId: 'test-reconnect', - lastSeenServerSeq: missedFromSeq, - subscriptions: [sessionUri], - }); - - assert.ok(result.type === 'replay' || result.type === 'snapshot', 'should receive replay or snapshot'); - if (result.type === 'replay') { - assert.ok(result.actions.length > 0, 'should have replayed actions'); - } - - client2.close(); - }); - - // ---- Gap tests: functionality bugs ---------------------------------------- - - test('usage info is captured on completed turn', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-usage'); - dispatchTurnStarted(client, sessionUri, 'turn-usage', 'with-usage', 1); - - const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'session/usage')); - const usageAction = getActionEnvelope(usageNotif).action as IUsageAction; - assert.strictEqual(usageAction.usage.inputTokens, 100); - assert.strictEqual(usageAction.usage.outputTokens, 50); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.ok(state.turns.length >= 1); - const turn = state.turns[state.turns.length - 1]; - assert.ok(turn.usage); - assert.strictEqual(turn.usage!.inputTokens, 100); - assert.strictEqual(turn.usage!.outputTokens, 50); - }); - - test('modifiedAt updates on turn completion', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-modifiedAt'); - - const initialSnapshot = await client.call('subscribe', { resource: sessionUri }); - const initialModifiedAt = (initialSnapshot.snapshot.state as ISessionState).summary.modifiedAt; - - await new Promise(resolve => setTimeout(resolve, 50)); - - dispatchTurnStarted(client, sessionUri, 'turn-mod', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const updatedSnapshot = await client.call('subscribe', { resource: sessionUri }); - const updatedModifiedAt = (updatedSnapshot.snapshot.state as ISessionState).summary.modifiedAt; - assert.ok(updatedModifiedAt >= initialModifiedAt); - }); - - test('createSession with invalid provider does not crash server', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-invalid-create' }); - - // This should return a JSON-RPC error - let gotError = false; - try { - await client.call('createSession', { session: nextSessionUri(), provider: 'nonexistent' }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should have received an error for invalid provider'); - - // Server should still be functional - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - assert.ok(notif); - }); - - test('fetchTurns returns completed turn history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns'); - - dispatchTurnStarted(client, sessionUri, 'turn-ft-1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - dispatchTurnStarted(client, sessionUri, 'turn-ft-2', 'hello', 2); - await new Promise(resolve => setTimeout(resolve, 200)); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const result = await client.call('fetchTurns', { session: sessionUri, limit: 10 }); - assert.ok(result.turns.length >= 2); - assert.strictEqual(typeof result.hasMore, 'boolean'); - }); - - // ---- Gap tests: coverage --------------------------------------------------- - - test('dispose session sends sessionRemoved notification', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-dispose'); - await client.call('disposeSession', { session: sessionUri }); - - const notif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - const removed = (notif.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; - assert.strictEqual(removed.session.toString(), sessionUri.toString()); - }); - - test('cancel turn stops in-progress processing', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-cancel'); - dispatchTurnStarted(client, sessionUri, 'turn-cancel', 'slow', 1); - - client.notify('dispatchAction', { - clientSeq: 2, - action: { type: 'session/turnCancelled', session: sessionUri, turnId: 'turn-cancel' }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnCancelled')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.ok(state.turns.length >= 1); - assert.strictEqual(state.turns[state.turns.length - 1].state, 'cancelled'); - }); - - test('multiple sequential turns accumulate in history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-multi-turns'); - - dispatchTurnStarted(client, sessionUri, 'turn-m1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - dispatchTurnStarted(client, sessionUri, 'turn-m2', 'hello', 2); - await new Promise(resolve => setTimeout(resolve, 200)); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); - assert.strictEqual(state.turns[0].id, 'turn-m1'); - assert.strictEqual(state.turns[1].id, 'turn-m2'); - }); - - test('two clients on same session both see actions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-multi-client-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-multi-client-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client2.clearReceived(); - - dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1); - - const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - assert.ok(d1); - assert.ok(d2); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client2.close(); - }); - - test('unsubscribe stops receiving session actions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-unsubscribe'); - client.notify('unsubscribe', { resource: sessionUri }); - await new Promise(resolve => setTimeout(resolve, 100)); - client.clearReceived(); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-unsub-helper' }); - await client2.call('subscribe', { resource: sessionUri }); - - dispatchTurnStarted(client2, sessionUri, 'turn-unsub', 'hello', 1); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - await new Promise(resolve => setTimeout(resolve, 300)); - const sessionActions = client.receivedNotifications(n => isActionNotification(n, 'session/')); - assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should not receive session actions'); - - client2.close(); - }); - - test('change model within session updates state', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-change-model'); - - client.notify('dispatchAction', { - clientSeq: 1, - action: { type: 'session/modelChanged', session: sessionUri, model: 'new-mock-model' }, - }); - - const modelChanged = await client.waitForNotification(n => isActionNotification(n, 'session/modelChanged')); - const action = getActionEnvelope(modelChanged).action; - assert.strictEqual(action.type, 'session/modelChanged'); - if (action.type === 'session/modelChanged') { - assert.strictEqual((action as { model: string }).model, 'new-mock-model'); - } - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.summary.model, 'new-mock-model'); - }); - - // ---- Session restore: subscribe to a session from a previous server lifetime - - test('subscribe to a pre-existing session restores turns from agent history', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-restore' }); - - // The mock agent seeds a pre-existing session that was never created - // through the server's handleCreateSession -- simulating a session - // from a previous server lifetime. - const preExistingUri = PRE_EXISTING_SESSION_URI.toString(); - const list = await client.call('listSessions'); - const preExisting = list.items.find(s => s.resource === preExistingUri); - assert.ok(preExisting, 'listSessions should include the pre-existing session'); - - // Clear notifications so we can verify no duplicate sessionAdded fires. - client.clearReceived(); - - // Subscribing to this session should trigger the restore path: the - // server fetches message history from the agent and reconstructs turns. - const result = await client.call('subscribe', { resource: preExistingUri }); - const state = result.snapshot.state as ISessionState; - - assert.strictEqual(state.lifecycle, 'ready', 'restored session should be in ready state'); - assert.ok(state.turns.length >= 1, `expected at least 1 restored turn but got ${state.turns.length}`); - - const turn = state.turns[0]; - assert.strictEqual(turn.userMessage.text, 'What files are here?'); - assert.strictEqual(turn.state, 'complete'); - const toolCallParts = turn.responseParts.filter((p): p is IToolCallResponsePart => p.kind === ResponsePartKind.ToolCall); - assert.ok(toolCallParts.length >= 1, 'turn should have tool call response parts'); - assert.strictEqual(toolCallParts[0].toolCall.toolName, 'list_files'); - const mdParts = turn.responseParts.filter((p): p is IMarkdownResponsePart => p.kind === ResponsePartKind.Markdown); - assert.ok(mdParts.some(p => p.content.includes('file1.ts')), 'turn should have markdown part mentioning file1.ts'); - - // Restoring should NOT emit a duplicate sessionAdded notification - // (the session is already known to clients via listSessions). - await new Promise(resolve => setTimeout(resolve, 200)); - const sessionAddedNotifs = client.receivedNotifications(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - assert.strictEqual(sessionAddedNotifs.length, 0, 'restore should not emit sessionAdded'); - }); - - // ---- Multi-client tests ----------------------------------------------------- - - test('sessionAdded notification is broadcast to all connected clients', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-1' }); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-2' }); - - client.clearReceived(); - client2.clearReceived(); - - await client.call('createSession', { session: nextSessionUri(), provider: 'mock' }); - - const n1 = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const n2 = await client2.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - assert.ok(n1, 'client 1 should receive sessionAdded'); - assert.ok(n2, 'client 2 should receive sessionAdded'); - - const uri1 = ((n1.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; - const uri2 = ((n2.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource; - assert.strictEqual(uri1, uri2, 'both clients should see the same session URI'); - - client2.close(); - }); - - test('sessionRemoved notification is broadcast to all connected clients', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-broadcast-remove-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-remove-2' }); - client2.clearReceived(); - - await client.call('disposeSession', { session: sessionUri }); - - const n1 = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - const n2 = await client2.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - assert.ok(n1, 'client 1 should receive sessionRemoved'); - assert.ok(n2, 'client 2 should receive sessionRemoved even without subscribing'); - - const removed1 = (n1.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; - const removed2 = (n2.params as INotificationBroadcastParams).notification as ISessionRemovedNotification; - assert.strictEqual(removed1.session.toString(), sessionUri.toString()); - assert.strictEqual(removed2.session.toString(), sessionUri.toString()); - - client2.close(); - }); - - test('client B sends message on session created by client A', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-cross-msg-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-msg-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client.clearReceived(); - client2.clearReceived(); - - // Client B dispatches the turn - dispatchTurnStarted(client2, sessionUri, 'turn-cross', 'hello', 1); - - const r1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - const r2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - assert.ok(r1, 'client A should see responsePart from client B turn'); - assert.ok(r2, 'client B should see its own responsePart'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client2.close(); - }); - - test('both clients receive full tool progress updates', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-tool-progress-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-tool-progress-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client.clearReceived(); - client2.clearReceived(); - - dispatchTurnStarted(client, sessionUri, 'turn-tool-mc', 'use-tool', 1); - - // Both clients should see the full tool lifecycle - for (const c of [client, client2]) { - await c.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await c.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - await c.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - await c.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await c.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - } - - client2.close(); - }); - - test('unsubscribed client receives no actions but still gets notifications', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-scoping-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-scoping-2' }); - // Client 2 does NOT subscribe to the session - client2.clearReceived(); - - dispatchTurnStarted(client, sessionUri, 'turn-scoped', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Give some time for any stray actions to arrive - await new Promise(resolve => setTimeout(resolve, 300)); - const sessionActions = client2.receivedNotifications(n => n.method === 'action'); - assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should receive no session actions'); - - // But disposing the session should still broadcast a notification - client2.clearReceived(); - await client.call('disposeSession', { session: sessionUri }); - - const removed = await client2.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved' - ); - assert.ok(removed, 'unsubscribed client should still receive sessionRemoved notification'); - - client2.close(); - }); - - test('late subscriber gets current state via snapshot', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-late-sub'); - dispatchTurnStarted(client, sessionUri, 'turn-late', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Client 2 joins after the turn has completed - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-late-sub-2' }); - - const result = await client2.call('subscribe', { resource: sessionUri }); - const state = result.snapshot.state as ISessionState; - assert.ok(state.turns.length >= 1, `late subscriber should see completed turn, got ${state.turns.length}`); - assert.strictEqual(state.turns[0].id, 'turn-late'); - assert.strictEqual(state.turns[0].state, 'complete'); - - client2.close(); - }); - - test('permission flow: client B confirms tool started by client A', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-cross-perm-1'); - - const client2 = new TestProtocolClient(server.port); - await client2.connect(); - await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-perm-2' }); - await client2.call('subscribe', { resource: sessionUri }); - client.clearReceived(); - client2.clearReceived(); - - // Client A starts the permission turn - dispatchTurnStarted(client, sessionUri, 'turn-cross-perm', 'permission', 1); - - // Both clients should see tool_start and tool_ready - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Client B confirms the tool call - client2.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-cross-perm', - toolCallId: 'tc-perm-1', - approved: true, - }, - }); - - // Both clients should see the response and turn completion - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client2.close(); - }); - - test('malformed JSON message returns parse error', async function () { - this.timeout(10_000); - - const raw = new TestProtocolClient(server.port); - await raw.connect(); - - const responsePromise = raw.waitForRawMessage(); - raw.sendRaw('this is not valid json{{{'); - - const response = await responsePromise as IJsonRpcErrorResponse; - assert.strictEqual(response.jsonrpc, '2.0'); - assert.strictEqual(response.id, null); - assert.strictEqual(response.error.code, JSON_RPC_PARSE_ERROR); - - raw.close(); - }); - - // ---- Edit auto-approve patterns ----------------------------------------- - - test('auto-approves write to regular file (no pending confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove', 'file:///workspace'); - client.clearReceived(); - - // Start a turn that triggers a write permission request for a regular .ts file - dispatchTurnStarted(client, sessionUri, 'turn-autoapprove', 'write-file', 1); - - // The write should be auto-approved — we should see tool_start, tool_complete, and turn_complete - // but NOT a pending-confirmation toolCallReady (one without `confirmed`). - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Verify no pending-confirmation toolCallReady was received - const pendingConfirmNotifs = client.receivedNotifications(n => { - if (!isActionNotification(n, 'session/toolCallReady')) { - return false; - } - const action = getActionEnvelope(n).action as { confirmed?: string }; - return !action.confirmed; - }); - assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for auto-approved write'); - }); - - test('blocks write to .env file (requires manual confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-autoapprove-deny', 'file:///workspace'); - client.clearReceived(); - - // Start a turn that tries to write .env (blocked by default patterns) - dispatchTurnStarted(client, sessionUri, 'turn-deny', 'write-env', 1); - - // The .env write should NOT be auto-approved — we should see toolCallReady (pending confirmation) - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Confirm it manually to let the turn complete - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-deny', - toolCallId: 'tc-write-env-1', - approved: true, - confirmed: 'user-action', - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // ---- Session rename / title -------------------------------------------------- - - test('client titleChanged updates session state snapshot', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-titleChanged'); - - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/titleChanged', - session: sessionUri, - title: 'My Custom Title', - }, - }); - - const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); - const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; - assert.strictEqual(titleAction.title, 'My Custom Title'); - - // Verify the snapshot reflects the new title - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.summary.title, 'My Custom Title'); - }); - - test('agent-generated titleChanged is broadcast', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-agent-title'); - dispatchTurnStarted(client, sessionUri, 'turn-title', 'with-title', 1); - - const titleNotif = await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); - const titleAction = getActionEnvelope(titleNotif).action as ITitleChangedAction; - assert.strictEqual(titleAction.title, MOCK_AUTO_TITLE); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Verify the snapshot reflects the auto-generated title - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.summary.title, MOCK_AUTO_TITLE); - }); - - test('renamed session title persists across listSessions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-title-list'); - - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/titleChanged', - session: sessionUri, - title: 'Persisted Title', - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/titleChanged')); - - // Poll listSessions until the persisted title appears (async DB write) - let session: { title: string } | undefined; - for (let i = 0; i < 20; i++) { - const result = await client.call('listSessions'); - session = result.items.find(s => s.resource === sessionUri); - if (session?.title === 'Persisted Title') { - break; - } - await timeout(100); - } - assert.ok(session, 'session should appear in listSessions'); - assert.strictEqual(session.title, 'Persisted Title'); - }); - - // ---- Reasoning events ------------------------------------------------------- - - test('reasoning events produce reasoning response parts and append actions', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-reasoning'); - dispatchTurnStarted(client, sessionUri, 'turn-reasoning', 'with-reasoning', 1); - - // The first reasoning event produces a responsePart with kind Reasoning - const reasoningPart = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/responsePart')) { - return false; - } - const action = getActionEnvelope(n).action as IResponsePartAction; - return action.part.kind === ResponsePartKind.Reasoning; - }); - const reasoningAction = getActionEnvelope(reasoningPart).action as IResponsePartAction; - assert.strictEqual(reasoningAction.part.kind, ResponsePartKind.Reasoning); - - // The second reasoning chunk produces a session/reasoning append action - const appendNotif = await client.waitForNotification(n => isActionNotification(n, 'session/reasoning')); - const appendAction = getActionEnvelope(appendNotif).action; - assert.strictEqual(appendAction.type, 'session/reasoning'); - if (appendAction.type === 'session/reasoning') { - assert.strictEqual(appendAction.content, ' about this...'); - } - - // Then the markdown response part - const mdPart = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/responsePart')) { - return false; - } - const action = getActionEnvelope(n).action as IResponsePartAction; - return action.part.kind === ResponsePartKind.Markdown; - }); - assert.ok(mdPart); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // ---- Queued messages -------------------------------------------------------- - - test('queued message is auto-consumed when session is idle', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-queue-idle'); - client.clearReceived(); - - // Queue a message when the session is idle — server should immediately consume it - client.notify('dispatchAction', { - clientSeq: 1, - action: { - type: 'session/pendingMessageSet', - session: sessionUri, - kind: PendingMessageKind.Queued, - id: 'q-1', - userMessage: { text: 'hello' }, - }, - }); - - // The server should auto-consume the queued message and start a turn - await client.waitForNotification(n => isActionNotification(n, 'session/turnStarted')); - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Verify the turn was created from the queued message - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.ok(state.turns.length >= 1); - assert.strictEqual(state.turns[state.turns.length - 1].userMessage.text, 'hello'); - // Queue should be empty after consumption - assert.ok(!state.queuedMessages?.length, 'queued messages should be empty after consumption'); - }); - - test('queued message waits for in-progress turn to complete', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-queue-wait'); - - // Start a turn first - dispatchTurnStarted(client, sessionUri, 'turn-first', 'hello', 1); - - // Wait for the first turn's response to confirm it is in progress - await client.waitForNotification(n => isActionNotification(n, 'session/responsePart')); - - // Queue a message while the turn is in progress - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/pendingMessageSet', - session: sessionUri, - kind: PendingMessageKind.Queued, - id: 'q-wait-1', - userMessage: { text: 'hello' }, - }, - }); - - // First turn should complete - const firstComplete = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/turnComplete')) { - return false; - } - return (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-first'; - }); - const firstSeq = getActionEnvelope(firstComplete).serverSeq; - - // The queued message's turn should complete AFTER the first turn - const secondComplete = await client.waitForNotification(n => { - if (!isActionNotification(n, 'session/turnComplete')) { - return false; - } - const envelope = getActionEnvelope(n); - return (envelope.action as { turnId: string }).turnId !== 'turn-first' - && envelope.serverSeq > firstSeq; - }); - assert.ok(secondComplete, 'should receive a second turnComplete from the queued message'); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.ok(state.turns.length >= 2, `expected >= 2 turns but got ${state.turns.length}`); - }); - - // ---- Steering messages ------------------------------------------------------ - - test('steering message is set and consumed by agent', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-steering'); - - // Start a turn first - dispatchTurnStarted(client, sessionUri, 'turn-steer', 'hello', 1); - - // Set a steering message while the turn is in progress - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/pendingMessageSet', - session: sessionUri, - kind: PendingMessageKind.Steering, - id: 'steer-1', - userMessage: { text: 'Please be concise' }, - }, - }); - - // The steering message should be set in state initially - const setNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageSet')); - assert.ok(setNotif, 'should see pendingMessageSet action'); - - // The mock agent consumes steering and fires steering_consumed, - // which causes the server to dispatch pendingMessageRemoved - const removedNotif = await client.waitForNotification(n => isActionNotification(n, 'session/pendingMessageRemoved')); - assert.ok(removedNotif, 'should see pendingMessageRemoved after agent consumes steering'); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Steering should be cleared from state - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.ok(!state.steeringMessage, 'steering message should be cleared after consumption'); - }); - - // ---- Shell auto-approve ------------------------------------------------- - - test('auto-approves allowed shell command (no pending confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-shell-approve'); - client.clearReceived(); - - // Start a turn that triggers a shell permission request for "ls -la" (allowed command) - dispatchTurnStarted(client, sessionUri, 'turn-shell-approve', 'run-safe-command', 1); - - // The shell command should be auto-approved — we should see tool_start, tool_complete, and turn_complete - // but NOT a pending-confirmation toolCallReady. - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete')); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - // Verify no pending-confirmation toolCallReady was received - const pendingConfirmNotifs = client.receivedNotifications(n => { - if (!isActionNotification(n, 'session/toolCallReady')) { - return false; - } - const action = getActionEnvelope(n).action as { confirmed?: string }; - return !action.confirmed; - }); - assert.strictEqual(pendingConfirmNotifs.length, 0, 'should not have received pending-confirmation toolCallReady for allowed shell command'); - }); - - test('blocks denied shell command (requires manual confirmation)', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-shell-deny'); - client.clearReceived(); - - // Start a turn that triggers a shell permission request for "rm -rf /" (denied command) - dispatchTurnStarted(client, sessionUri, 'turn-shell-deny', 'run-dangerous-command', 1); - - // The denied command should NOT be auto-approved — we should see toolCallReady (pending confirmation) - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')); - await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')); - - // Confirm it manually to let the turn complete - client.notify('dispatchAction', { - clientSeq: 2, - action: { - type: 'session/toolCallConfirmed', - session: sessionUri, - turnId: 'turn-shell-deny', - toolCallId: 'tc-shell-deny-1', - approved: true, - confirmed: 'user-action', - }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - }); - - // ---- Truncation tests --------------------------------------------------- - - test('truncate session removes turns after specified turn', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-truncate'); - - // Create two turns - dispatchTurnStarted(client, sessionUri, 'turn-t1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t1'); - - client.clearReceived(); - dispatchTurnStarted(client, sessionUri, 'turn-t2', 'hello', 2); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-t2'); - - // Verify 2 turns exist - let snapshot = await client.call('subscribe', { resource: sessionUri }); - let state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.turns.length, 2); - - client.clearReceived(); - - // Truncate: keep only turn-t1 - client.notify('dispatchAction', { - clientSeq: 3, - action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-t1' }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - - snapshot = await client.call('subscribe', { resource: sessionUri }); - state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.turns.length, 1); - assert.strictEqual(state.turns[0].id, 'turn-t1'); - }); - - test('truncate all turns clears session history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-truncate-all'); - - dispatchTurnStarted(client, sessionUri, 'turn-ta1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - client.clearReceived(); - - // Truncate all (no turnId) - client.notify('dispatchAction', { - clientSeq: 2, - action: { type: 'session/truncated', session: sessionUri }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.turns.length, 0); - }); - - test('new turn after truncation works correctly', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-truncate-resume'); - - dispatchTurnStarted(client, sessionUri, 'turn-tr1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr1'); - - client.clearReceived(); - dispatchTurnStarted(client, sessionUri, 'turn-tr2', 'hello', 2); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-tr2'); - - client.clearReceived(); - - // Truncate to turn-tr1 - client.notify('dispatchAction', { - clientSeq: 3, - action: { type: 'session/truncated', session: sessionUri, turnId: 'turn-tr1' }, - }); - - await client.waitForNotification(n => isActionNotification(n, 'session/truncated')); - - // Send a new turn after truncation - dispatchTurnStarted(client, sessionUri, 'turn-tr3', 'hello', 4); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete')); - - const snapshot = await client.call('subscribe', { resource: sessionUri }); - const state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.turns.length, 2); - assert.strictEqual(state.turns[0].id, 'turn-tr1'); - assert.strictEqual(state.turns[1].id, 'turn-tr3'); - }); - - // ---- Fork tests --------------------------------------------------------- - - test('fork creates a new session with source history', async function () { - this.timeout(15_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fork'); - - // Create two turns - dispatchTurnStarted(client, sessionUri, 'turn-f1', 'hello', 1); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f1'); - - client.clearReceived(); - dispatchTurnStarted(client, sessionUri, 'turn-f2', 'hello', 2); - await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete') && (getActionEnvelope(n).action as { turnId: string }).turnId === 'turn-f2'); - - client.clearReceived(); - - // Fork at turn-f1 (keep turns up to and including turn-f1) - const forkedSessionUri = nextSessionUri(); - await client.call('createSession', { - session: forkedSessionUri, - provider: 'mock', - fork: { session: sessionUri, turnId: 'turn-f1' }, - }); - - const addedNotif = await client.waitForNotification(n => - n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded' - ); - const addedSession = (addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification; - - // Subscribe — forked session should have 1 turn (from the protocol state - // populated during createSession with fork params). - const snapshot = await client.call('subscribe', { resource: addedSession.summary.resource }); - const state = snapshot.snapshot.state as ISessionState; - assert.strictEqual(state.lifecycle, 'ready'); - assert.strictEqual(state.turns.length, 1, 'forked session should have 1 turn'); - - // Source session should be unaffected - const sourceSnapshot = await client.call('subscribe', { resource: sessionUri }); - const sourceState = sourceSnapshot.snapshot.state as ISessionState; - assert.strictEqual(sourceState.turns.length, 2); - }); - - test('fork with invalid turn ID returns error', async function () { - this.timeout(10_000); - - const sessionUri = await createAndSubscribeSession(client, 'test-fork-invalid'); - - let gotError = false; - try { - await client.call('createSession', { - session: nextSessionUri(), - provider: 'mock', - fork: { session: sessionUri, turnId: 'nonexistent-turn' }, - }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should get error for invalid fork turn ID'); - }); - - test('fork with invalid source session returns error', async function () { - this.timeout(10_000); - - await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-fork-no-source' }); - - let gotError = false; - try { - await client.call('createSession', { - session: nextSessionUri(), - provider: 'mock', - fork: { session: 'mock://nonexistent-session', turnId: 'turn-1' }, - }); - } catch { - gotError = true; - } - assert.ok(gotError, 'should get error for invalid fork source session'); - }); -}); diff --git a/src/vs/platform/browserElements/common/browserElements.ts b/src/vs/platform/browserElements/common/browserElements.ts index 7e8d1beac191c..cec24f0668a0f 100644 --- a/src/vs/platform/browserElements/common/browserElements.ts +++ b/src/vs/platform/browserElements/common/browserElements.ts @@ -16,6 +16,7 @@ export interface IElementAncestor { } export interface IElementData { + readonly url?: string; readonly outerHTML: string; readonly computedStyle: string; readonly bounds: IRectangle; diff --git a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts index 16533387380d6..2eaa22425921f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts @@ -82,7 +82,7 @@ export class BrowserViewElementInspector extends Disposable { private readonly _connectionPromise: Promise; - constructor(browser: BrowserView) { + constructor(private readonly browser: BrowserView) { super(); this._connectionPromise = browser.attach().then( @@ -136,7 +136,10 @@ export class BrowserViewElementInspector extends Disposable { try { const nodeData = await extractNodeData(connection, { backendNodeId: params.backendNodeId }); - resolve(nodeData); + resolve({ + ...nodeData, + url: this.browser.getURL() + }); } catch (err) { reject(err); } @@ -179,7 +182,11 @@ export class BrowserViewElementInspector extends Disposable { return undefined; } - return extractNodeData(connection, { objectId: result.objectId }); + const nodeData = await extractNodeData(connection, { objectId: result.objectId }); + return { + ...nodeData, + url: this.browser.getURL() + }; } async getVisualViewportScale(): Promise { diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index ceddf207a6e78..1bdfa3557b6ac 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -7,7 +7,7 @@ import type * as playwright from 'playwright-core'; import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; +import { createCancelablePromise, raceCancellablePromises, timeout } from '../../../base/common/async.js'; type IAiAriaSnapshotOptions = NonNullable[0]> & { _track?: string }; @@ -199,7 +199,7 @@ export class PlaywrightTab { `Recent events:`, ...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`) ] : []), - `Snapshot: ${snapshotFromPage ? snapshot ? `\n${snapshot}` : '' : ''}`, + `Snapshot: ${snapshotFromPage !== undefined ? snapshot ? `\n${snapshot}` : '' : ''}`, ].join('\n'); } @@ -238,8 +238,10 @@ export class PlaywrightTab { if (['document', 'stylesheet', 'script', 'xhr', 'fetch'].includes(request.resourceType())) { promises.push(request.response().then(r => r?.finished()).catch(() => { })); } else { promises.push(request.response().catch(() => { })); } } - const timeout = new Promise(resolve => setTimeout(resolve, 5000)); - await Promise.race([Promise.all(promises), timeout]); + await raceCancellablePromises([ + Promise.all(promises), + timeout(5000) // Don't wait indefinitely for requests to finish + ]); return result; } diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts index 9507f2fd7ebd6..d6802947d29f2 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -57,6 +57,7 @@ export class McpGatewayChannel extends Disposable implements IServerCh switch (command) { case 'createGateway': { + const { chatSessionResource } = (args as { chatSessionResource?: string } | undefined) ?? {}; const brokerChannel = ipcChannelForContext(this._ipcServer, ctx); // Fetch initial server list before creating the gateway (IPC is async, but the invoker interface is sync) @@ -72,7 +73,7 @@ export class McpGatewayChannel extends Disposable implements IServerCh onDidChangeResources: brokerChannel.listen('onDidChangeResources'), listServers: () => currentServers, listToolsForServer: serverId => brokerChannel.call('listToolsForServer', { serverId }), - callToolForServer: (serverId, name, callArgs) => brokerChannel.call('callToolForServer', { serverId, name, args: callArgs }), + callToolForServer: (serverId, name, callArgs) => brokerChannel.call('callToolForServer', { serverId, name, args: callArgs, chatSessionResource }), listResourcesForServer: serverId => brokerChannel.call('listResourcesForServer', { serverId }), readResourceForServer: (serverId, uri) => brokerChannel.call('readResourceForServer', { serverId, uri }), listResourceTemplatesForServer: serverId => brokerChannel.call('listResourceTemplatesForServer', { serverId }), diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 8abc12decd9af..371478fbe9a40 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -341,12 +341,17 @@ export abstract class AbstractUpdateService implements IUpdateService { } } + // Remember the Ready state so we can restore it if the quit is vetoed + const readyState = this.state; + this.setState(State.Restarting(this.state.update)); this.logService.trace('update#quitAndInstall(): before lifecycle quit()'); this.lifecycleMainService.quit(true /* will restart */).then(vetod => { this.logService.trace(`update#quitAndInstall(): after lifecycle quit() with veto: ${vetod}`); if (vetod) { + this.logService.info('update#quitAndInstall(): quit was vetoed, restoring Ready state'); + this.setState(readyState); return; } diff --git a/src/vs/platform/update/electron-main/updateService.snap.ts b/src/vs/platform/update/electron-main/updateService.snap.ts index 6b941c8985480..0dc5dbd8888de 100644 --- a/src/vs/platform/update/electron-main/updateService.snap.ts +++ b/src/vs/platform/update/electron-main/updateService.snap.ts @@ -115,11 +115,17 @@ abstract class AbstractUpdateService implements IUpdateService { return Promise.resolve(undefined); } + // Remember the Ready state so we can restore it if the quit is vetoed + const readyState = this.state; + + this.setState(State.Restarting(this.state.update)); this.logService.trace('update#quitAndInstall(): before lifecycle quit()'); this.lifecycleMainService.quit(true /* will restart */).then(vetod => { this.logService.trace(`update#quitAndInstall(): after lifecycle quit() with veto: ${vetod}`); if (vetod) { + this.logService.info('update#quitAndInstall(): quit was vetoed, restoring Ready state'); + this.setState(readyState); return; } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 0edfdbbbf78cc..b96db11f6d4cb 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -227,13 +227,12 @@ Browser compatibility is required — no Node.js APIs. ## Feature Gating -All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizationsMenu.enabled` setting. +All commands and UI respect `ChatContextKeys.enabled`. ## Settings -Settings use the `chat.customizationsMenu.` and `chat.customizations.` namespaces: +User-facing settings use the `chat.customizations.` namespace: | Setting | Default | Description | |---------|---------|-------------| -| `chat.customizationsMenu.enabled` | `true` | Show the Chat Customizations editor in the Command Palette | | `chat.customizations.harnessSelector.enabled` | `true` | Show the harness selector dropdown in the sidebar | diff --git a/src/vs/sessions/common/welcome.ts b/src/vs/sessions/common/welcome.ts new file mode 100644 index 0000000000000..a43463a2f316d --- /dev/null +++ b/src/vs/sessions/common/welcome.ts @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; diff --git a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts index 0bb49b413ad72..8f94c7c160923 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts +++ b/src/vs/sessions/contrib/accountMenu/browser/account.contribution.ts @@ -9,6 +9,7 @@ import './media/accountTitleBarWidget.css'; import '../../../../workbench/contrib/chat/browser/chatStatus/media/chatStatus.css'; import Severity from '../../../../base/common/severity.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Event } from '../../../../base/common/event.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, registerAction2, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -33,7 +34,7 @@ import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { IHostService } from '../../../../workbench/services/host/browser/host.js'; import { URI } from '../../../../base/common/uri.js'; import { UpdateHoverWidget } from './updateHoverWidget.js'; -import { ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ChatStatusDashboard } from '../../../../workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; @@ -43,6 +44,11 @@ import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextke import { IAuthenticationAccessService } from '../../../../workbench/services/authentication/browser/authenticationAccessService.js'; import { IAuthenticationUsageService } from '../../../../workbench/services/authentication/browser/authenticationUsageService.js'; import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { resetSessionsWelcome } from '../../welcome/browser/welcome.contribution.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; // --- Account Menu Items --- // const AccountMenu = new MenuId('SessionsAccountMenu'); @@ -149,6 +155,22 @@ async function runSessionsUpdateAction( } } +export async function showSessionsWelcomeAfterSignOut( + chatEntitlementService: Pick, + resetWelcome: () => void, +): Promise { + const waitForUnknownEntitlement = Event.toPromise(Event.filter(chatEntitlementService.onDidChangeEntitlement, () => chatEntitlementService.entitlement === ChatEntitlement.Unknown)); + try { + if (chatEntitlementService.entitlement !== ChatEntitlement.Unknown) { + await waitForUnknownEntitlement; + } + } finally { + waitForUnknownEntitlement.cancel(); + } + + resetWelcome(); +} + // Sign In (shown when signed out) registerAction2(class extends Action2 { constructor() { @@ -189,6 +211,13 @@ registerAction2(class extends Action2 { const authenticationService = accessor.get(IAuthenticationService); const authenticationUsageService = accessor.get(IAuthenticationUsageService); const authenticationAccessService = accessor.get(IAuthenticationAccessService); + const chatEntitlementService = accessor.get(IChatEntitlementService); + const storageService = accessor.get(IStorageService); + const instantiationService = accessor.get(IInstantiationService); + const layoutService = accessor.get(IWorkbenchLayoutService); + const contextKeyService = accessor.get(IContextKeyService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); + const logService = accessor.get(ILogService); const defaultAccount = await defaultAccountService.getDefaultAccount(); if (!defaultAccount) { return; @@ -212,6 +241,7 @@ registerAction2(class extends Action2 { await Promise.all(sessions.map(session => authenticationService.removeSession(providerId, session.id))); authenticationUsageService.removeAccountUsage(providerId, accountLabel); authenticationAccessService.removeAllowedExtensions(providerId, accountLabel); + await showSessionsWelcomeAfterSignOut(chatEntitlementService, () => resetSessionsWelcome(storageService, instantiationService, layoutService, chatEntitlementService, contextKeyService, environmentService, logService)); } }); diff --git a/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts new file mode 100644 index 0000000000000..2060ba8539e5b --- /dev/null +++ b/src/vs/sessions/contrib/accountMenu/test/browser/account.contribution.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ChatEntitlement, IChatEntitlementService } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { showSessionsWelcomeAfterSignOut } from '../../browser/account.contribution.js'; + +suite('Sessions - Account Contribution', () => { + + const disposables = new DisposableStore(); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('shows welcome after sign out once entitlement becomes unknown', async () => { + const order: string[] = []; + const entitlementChangeEmitter = disposables.add(new Emitter()); + let entitlement = ChatEntitlement.Free; + + const chatEntitlementService: Pick = { + get entitlement() { + return entitlement; + }, + onDidChangeEntitlement: entitlementChangeEmitter.event, + }; + const showWelcomePromise = showSessionsWelcomeAfterSignOut(chatEntitlementService, () => order.push('resetWelcome')); + order.push('signOut'); + entitlement = ChatEntitlement.Unknown; + entitlementChangeEmitter.fire(); + order.push('entitlementChanged'); + await showWelcomePromise; + + assert.deepStrictEqual(order, [ + 'signOut', + 'entitlementChanged', + 'resetWelcome', + ]); + }); + + test('shows welcome immediately when entitlement is already unknown', async () => { + const order: string[] = []; + const chatEntitlementService: Pick = { + entitlement: ChatEntitlement.Unknown, + onDidChangeEntitlement: disposables.add(new Emitter()).event, + }; + await showSessionsWelcomeAfterSignOut(chatEntitlementService, () => order.push('resetWelcome')); + + assert.deepStrictEqual(order, ['resetWelcome']); + }); + + test('handles entitlement becoming unknown while the listener is being attached', async () => { + const order: string[] = []; + let entitlement = ChatEntitlement.Free; + const onDidChangeEntitlement: IChatEntitlementService['onDidChangeEntitlement'] = listener => { + entitlement = ChatEntitlement.Unknown; + listener(); + return { dispose() { } }; + }; + const chatEntitlementService: Pick = { + get entitlement() { + return entitlement; + }, + onDidChangeEntitlement, + }; + await showSessionsWelcomeAfterSignOut(chatEntitlementService, () => order.push('resetWelcome')); + + assert.deepStrictEqual(order, ['resetWelcome']); + }); +}); diff --git a/src/vs/sessions/contrib/changes/browser/checksWidget.ts b/src/vs/sessions/contrib/changes/browser/checksWidget.ts index 5b9b61c2f3156..6e974f9f5a20f 100644 --- a/src/vs/sessions/contrib/changes/browser/checksWidget.ts +++ b/src/vs/sessions/contrib/changes/browser/checksWidget.ts @@ -274,7 +274,7 @@ export class CIStatusWidget extends Disposable { }, }, )); - this._bodyNode.appendChild(this._list.getHTMLElement()); + this._bodyNode.appendChild(listContainer); } setInput(input: ChecksViewModel): IDisposable { diff --git a/src/vs/sessions/contrib/changes/browser/media/changesView.css b/src/vs/sessions/contrib/changes/browser/media/changesView.css index a8ae8364fd444..61cc3f34c2b2d 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesView.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesView.css @@ -254,7 +254,7 @@ } .changes-view-body .chat-editing-session-container.show-file-icons .monaco-scrollable-element .monaco-list-rows .monaco-list-row { - border-radius: 2px; + border-radius: 4px; } /* Action bar in list rows */ diff --git a/src/vs/sessions/contrib/changes/browser/media/checksWidget.css b/src/vs/sessions/contrib/changes/browser/media/checksWidget.css index c29988b93a6da..bfcdd7b7424bf 100644 --- a/src/vs/sessions/contrib/changes/browser/media/checksWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/checksWidget.css @@ -145,6 +145,7 @@ } .ci-status-widget-list { + height: 100%; background-color: transparent; } diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css index 64a59c02e353b..9ed72a28310a9 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css @@ -222,6 +222,10 @@ overflow: hidden; } +.sessions-chat-picker-slot .action-label.hidden { + display: none; +} + .sessions-chat-picker-slot .action-label:hover { background-color: var(--vscode-toolbar-hoverBackground); color: var(--vscode-foreground); diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 79ce383ecaf24..c08d3710e24b7 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -656,7 +656,12 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { * Handles a workspace selection from the workspace picker. * Requests folder trust if needed and creates a new session. */ - private async _onWorkspaceSelected(selection: IWorkspaceSelection): Promise { + private async _onWorkspaceSelected(selection: IWorkspaceSelection | undefined): Promise { + if (!selection) { + this.sessionsManagementService.unsetNewSession(); + return; + } + if (selection.workspace.requiresWorkspaceTrust) { const workspaceUri = selection.workspace.repositories[0]?.uri; if (workspaceUri && !await this._requestFolderTrust(workspaceUri)) { diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index e0d7337b0ae25..1ad0c8f667611 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -20,7 +20,6 @@ export class SessionTypePicker extends Disposable { private _sessionTypes: ISessionType[] = []; private readonly _renderDisposables = this._register(new DisposableStore()); - private _slotElement: HTMLElement | undefined; private _triggerElement: HTMLElement | undefined; constructor( @@ -46,7 +45,6 @@ export class SessionTypePicker extends Disposable { this._renderDisposables.clear(); const slot = dom.append(container, dom.$('.sessions-chat-picker-slot')); - this._slotElement = slot; this._renderDisposables.add({ dispose: () => slot.remove() }); const trigger = dom.append(slot, dom.$('a.action-label')); @@ -120,6 +118,12 @@ export class SessionTypePicker extends Disposable { dom.clearNode(this._triggerElement); + if (this._sessionTypes.length === 0) { + this._triggerElement.classList.add('hidden'); + return; + } + + this._triggerElement.classList.remove('hidden'); const currentType = this._sessionTypes.find(t => t.id === this._sessionType); const modeIcon = currentType?.icon ?? Codicon.terminal; const modeLabel = currentType?.label ?? this._sessionType ?? ''; @@ -128,8 +132,6 @@ export class SessionTypePicker extends Disposable { const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = modeLabel; - const hasMultipleTypes = this._sessionTypes.length > 1; - this._slotElement?.classList.toggle('disabled', !hasMultipleTypes); dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 317d73b7b64fb..0dbd7345f3d4b 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../base/browser/dom.js'; -import { SubmenuAction, toAction } from '../../../../base/common/actions.js'; +import { IAction, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; @@ -14,8 +14,11 @@ import { Schemas } from '../../../../base/common/network.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { TUNNEL_ADDRESS_PREFIX } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IPreferencesService } from '../../../../workbench/services/preferences/common/preferences.js'; import { IOutputService } from '../../../../workbench/services/output/common/output.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; @@ -62,6 +65,8 @@ interface IWorkspacePickerItem { readonly checked?: boolean; /** Remote provider reference for gear menu actions. */ readonly remoteProvider?: ISessionsProvider; + /** When true, clicking this item triggers the tunnel connection command. */ + readonly tunnelAction?: boolean; } /** @@ -72,8 +77,8 @@ interface IWorkspacePickerItem { */ export class WorkspacePicker extends Disposable { - private readonly _onDidSelectWorkspace = this._register(new Emitter()); - readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; + private readonly _onDidSelectWorkspace = this._register(new Emitter()); + readonly onDidSelectWorkspace: Event = this._onDidSelectWorkspace.event; private readonly _onDidChangeSelection = this._register(new Emitter()); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; @@ -98,6 +103,8 @@ export class WorkspacePicker extends Disposable { @IClipboardService private readonly clipboardService: IClipboardService, @IPreferencesService private readonly preferencesService: IPreferencesService, @IOutputService private readonly outputService: IOutputService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @ICommandService private readonly commandService: ICommandService, ) { super(); @@ -182,13 +189,20 @@ export class WorkspacePicker extends Disposable { const delegate: IActionListDelegate = { onSelect: (item) => { this.actionWidgetService.hide(); - if (item.selection && this._isProviderUnavailable(item.selection.providerId)) { + if (item.tunnelAction) { + this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel'); + } else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) { // Workspace belongs to an unavailable remote — ignore selection return; } if (item.remoteProvider && item.browseActionIndex === undefined) { - // Disconnected remote host — show options menu after widget hides - this._showRemoteHostOptionsDelayed(item.remoteProvider); + if (item.remoteProvider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { + // Disconnected tunnel — trigger connection flow + this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel'); + } else { + // Disconnected SSH host — show options menu after widget hides + this._showRemoteHostOptionsDelayed(item.remoteProvider); + } } else if (item.browseActionIndex !== undefined) { this._executeBrowseAction(item.browseActionIndex); } else if (item.selection) { @@ -414,37 +428,57 @@ export class WorkspacePicker extends Disposable { } } + if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator && remoteProviders.length) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + for (const provider of remoteProviders) { const status = provider.connectionStatus!.get(); const isConnected = status === RemoteAgentHostConnectionStatus.Connected; const providerBrowseIndex = allBrowseActions.findIndex(a => a.providerId === provider.id); - if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { - items.push({ kind: ActionListItemKind.Separator, label: '' }); + const toolbarActions: IAction[] = []; + + // Gear menu only for SSH hosts, not tunnel providers + if (!provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX)) { + toolbarActions.push(toAction({ + id: `workspacePicker.remote.gear.${provider.id}`, + label: localize('workspacePicker.remoteOptions', "Options"), + class: ThemeIcon.asClassName(Codicon.gear), + run: () => { + this.actionWidgetService.hide(); + this._showRemoteHostOptionsDelayed(provider); + }, + })); } + const isTunnel = provider.remoteAddress?.startsWith(TUNNEL_ADDRESS_PREFIX); + items.push({ kind: ActionListItemKind.Action, label: provider.label, description: this._getStatusDescription(status), hover: { content: this._getStatusHover(status, provider.remoteAddress) }, - group: { title: '', icon: Codicon.remote }, - disabled: !isConnected, + group: { title: '', icon: isTunnel ? Codicon.cloud : Codicon.remote }, + disabled: isTunnel ? false : !isConnected, item: { browseActionIndex: isConnected && providerBrowseIndex >= 0 ? providerBrowseIndex : undefined, remoteProvider: provider, }, - toolbarActions: [ - toAction({ - id: `workspacePicker.remote.gear.${provider.id}`, - label: localize('workspacePicker.remoteOptions', "Options"), - class: ThemeIcon.asClassName(Codicon.gear), - run: () => { - this.actionWidgetService.hide(); - this._showRemoteHostOptionsDelayed(provider); - }, - }), - ], + toolbarActions, + }); + } + + // "Tunnels..." entry — shown when remote agent hosts are enabled + if (this.configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { + items.push({ kind: ActionListItemKind.Separator, label: '' }); + } + items.push({ + kind: ActionListItemKind.Action, + label: localize('workspacePicker.tunnels', "Tunnels..."), + group: { title: '', icon: Codicon.cloud }, + item: { tunnelAction: true }, }); } @@ -793,7 +827,7 @@ export class WorkspacePicker extends Disposable { this.actionWidgetService.hide(); this._selectedWorkspace = undefined; this._updateTriggerLabel(); - this._onDidChangeSelection.fire(); + this._onDidSelectWorkspace.fire(undefined); } } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index 06633cd58c406..9c148231abe1f 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -312,7 +312,11 @@ suite('WorkspacePicker - Connection Status', () => { const picker = createTestPicker(disposables, providersService, storage); const selected: IWorkspaceSelection[] = []; - disposables.add(picker.onDidSelectWorkspace(w => selected.push(w))); + disposables.add(picker.onDidSelectWorkspace(w => { + if (w) { + selected.push(w); + } + })); // Disconnect then reconnect remoteStatus.set(RemoteAgentHostConnectionStatus.Disconnected, undefined); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 3cb3496d121d3..92e5b2efafe92 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -10,7 +10,7 @@ import { CancellationError } from '../../../../base/common/errors.js'; import { IMarkdownString, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun, constObservable, derived, IObservable, IReader, observableFromEvent, observableValue, transaction } from '../../../../base/common/observable.js'; -import { themeColorFromId, ThemeIcon } from '../../../../base/common/themables.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; @@ -41,6 +41,9 @@ import { localize } from '../../../../nls.js'; import { SessionsGroupModel } from '../../sessions/browser/sessionsGroupModel.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { computePullRequestIcon, GitHubPullRequestState } from '../../github/common/types.js'; export interface ICopilotChatSession { /** Globally unique session ID (`providerId:localId`). */ @@ -386,7 +389,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { } update(agentSession: IAgentSession): void { - const session = new AgentSessionAdapter(agentSession, this.providerId); + const session = new AgentSessionAdapter(agentSession, this.providerId, undefined); this._workspaceData.set(session.workspace.get(), undefined); this._title.set(session.title.get(), undefined); this._status.set(session.status.get(), undefined); @@ -702,7 +705,7 @@ class AgentSessionAdapter implements ICopilotChatSession { private readonly _lastTurnEnd: ReturnType>; readonly lastTurnEnd: IObservable; - private readonly _gitHubInfo: ReturnType>; + private readonly _baseGitHubInfo: ReturnType>; readonly gitHubInfo: IObservable; readonly permissionLevel: IObservable = constObservable(ChatPermissionLevel.Default); @@ -714,6 +717,7 @@ class AgentSessionAdapter implements ICopilotChatSession { constructor( session: IAgentSession, providerId: string, + private readonly _gitHubService: IGitHubService | undefined, ) { this.id = `${providerId}:${session.resource.toString()}`; this.resource = session.resource; @@ -749,8 +753,21 @@ class AgentSessionAdapter implements ICopilotChatSession { this.description = this._description; this._lastTurnEnd = observableValue(this, session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined); this.lastTurnEnd = this._lastTurnEnd; - this._gitHubInfo = observableValue(this, this._extractGitHubInfo(session)); - this.gitHubInfo = this._gitHubInfo; + this._baseGitHubInfo = observableValue(this, this._extractGitHubInfo(session)); + this.gitHubInfo = this._gitHubService + ? derived(this, reader => { + const base = this._baseGitHubInfo.read(reader); + if (!base?.pullRequest || !this._gitHubService) { + return base; + } + const prModel = this._gitHubService.getPullRequest(base.owner, base.repo, base.pullRequest.number); + const livePR = prModel.pullRequest.read(reader); + if (!livePR) { + return base; + } + return { ...base, pullRequest: { ...base.pullRequest, icon: computePullRequestIcon(livePR.isDraft ? 'draft' : livePR.state) } }; + }) + : this._baseGitHubInfo; } setPermissionLevel(level: ChatPermissionLevel): void { @@ -783,7 +800,7 @@ class AgentSessionAdapter implements ICopilotChatSession { this._isRead.set(session.isRead(), tx); this._description.set(this._extractDescription(session), tx); this._lastTurnEnd.set(session.timing.lastRequestEnded ? new Date(session.timing.lastRequestEnded) : undefined, tx); - this._gitHubInfo.set(this._extractGitHubInfo(session), tx); + this._baseGitHubInfo.set(this._extractGitHubInfo(session), tx); }); } @@ -883,17 +900,8 @@ class AgentSessionAdapter implements ICopilotChatSession { private _extractPullRequestStateIcon(session: IAgentSession): ThemeIcon | undefined { const metadata = session.metadata; const state = metadata?.pullRequestState; - if (state) { - switch (state) { - case 'merged': - return { ...Codicon.gitPullRequestDone, color: themeColorFromId('charts.purple') }; - case 'closed': - return { ...Codicon.gitPullRequestClosed, color: themeColorFromId('charts.red') }; - case 'draft': - return { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }; - default: - return { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }; - } + if (typeof state === 'string') { + return computePullRequestIcon(state as GitHubPullRequestState | 'draft'); } return undefined; } @@ -1057,6 +1065,8 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IStorageService storageService: IStorageService, @IConfigurationService private readonly configurationService: IConfigurationService, + @ILogService private readonly logService: ILogService, + @IGitHubService private readonly gitHubService: IGitHubService, ) { super(); @@ -1372,6 +1382,11 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } // Send request + this.logService.debug(`[CopilotChatSessionsProvider] Sending first chat for session ${session.id} with options:`, { + userSelectedModelId: sendOptions.userSelectedModelId, + modeInfo: sendOptions.modeInfo, + agentIdSilent: sendOptions.agentIdSilent, + }); const result = await this.chatService.sendRequest(session.resource, query, sendOptions); if (result.kind === 'rejected') { throw new Error(`[DefaultCopilotProvider] sendRequest rejected: ${result.reason}`); @@ -1811,7 +1826,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions existing.update(session); changedData.push(existing); } else { - const adapter = new AgentSessionAdapter(session, this.id); + const adapter = new AgentSessionAdapter(session, this.id, this.gitHubService); this._sessionCache.set(key, adapter); addedData.push(adapter); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts index 19a14fadf9a3c..145df0396cca8 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -16,6 +16,7 @@ import { IChatSessionsService } from '../../../../workbench/contrib/chat/common/ import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { Target } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AICustomizationManagementCommands } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; @@ -148,7 +149,7 @@ export class ModePicker extends Disposable { if (item.kind === 'mode') { this._selectMode(item.mode); } else { - this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor); + this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, AICustomizationManagementSection.Agents); } }, onHide: () => { triggerElement.focus(); }, diff --git a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 0bdb9dddae067..777b6b166c44b 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -32,6 +32,7 @@ import { IGitService } from '../../../../../workbench/contrib/git/common/gitServ import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; // ---- Helpers ---------------------------------------------------------------- @@ -184,6 +185,7 @@ function createProviderForSendTests( const configService = new TestConfigurationService(); configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', false); + instantiationService.stub(ILogService, NullLogService); instantiationService.stub(IConfigurationService, configService); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); instantiationService.stub(IFileDialogService, {}); diff --git a/src/vs/sessions/contrib/github/common/types.ts b/src/vs/sessions/contrib/github/common/types.ts index 5c918dd231df3..723b061de737c 100644 --- a/src/vs/sessions/contrib/github/common/types.ts +++ b/src/vs/sessions/contrib/github/common/types.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Codicon } from '../../../../base/common/codicons.js'; +import { themeColorFromId, ThemeIcon } from '../../../../base/common/themables.js'; + //#region Session Context /** @@ -87,6 +90,24 @@ export interface IGitHubPullRequestMergeability { readonly blockers: readonly IMergeBlocker[]; } +/** + * Compute the PR status icon from a state value. + * Accepts both the `GitHubPullRequestState` enum values and the + * metadata-only `'draft'` value the extension writes to session metadata. + */ +export function computePullRequestIcon(state: GitHubPullRequestState | 'draft'): ThemeIcon { + switch (state) { + case GitHubPullRequestState.Merged: + return { ...Codicon.gitPullRequestDone, color: themeColorFromId('charts.purple') }; + case GitHubPullRequestState.Closed: + return { ...Codicon.gitPullRequestClosed, color: themeColorFromId('charts.red') }; + case 'draft': + return { ...Codicon.gitPullRequestDraft, color: themeColorFromId('descriptionForeground') }; + case GitHubPullRequestState.Open: + return { ...Codicon.gitPullRequest, color: themeColorFromId('charts.green') }; + } +} + //#endregion //#region Review Comments & Threads diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index 9daca1536ee5c..a68cd9716122f 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -100,6 +100,13 @@ class LocalSessionAdapter implements ISession { ? LocalAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory) : undefined); + if (metadata.isRead === false) { + this.isRead.set(false, undefined); + } + if (metadata.isDone) { + this.isArchived.set(true, undefined); + } + this.mainChat = { resource: this.resource, createdAt: this.createdAt, @@ -139,6 +146,16 @@ class LocalSessionAdapter implements ISession { didChange = true; } + if (metadata.isRead !== undefined && metadata.isRead !== this.isRead.get()) { + this.isRead.set(metadata.isRead, undefined); + didChange = true; + } + + if (metadata.isDone !== undefined && metadata.isDone !== this.isArchived.get()) { + this.isArchived.set(metadata.isDone, undefined); + didChange = true; + } + return didChange; } } @@ -222,6 +239,10 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi this._refreshSessions(); } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { this._handleTitleChanged(e.action.session, e.action.title); + } else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) { + this._handleIsReadChanged(e.action.session, e.action.isRead); + } else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) { + this._handleIsDoneChanged(e.action.session, e.action.isDone); } })); } @@ -332,12 +353,26 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi // -- Session Actions -- - async archiveSession(_sessionId: string): Promise { - // Agent host sessions don't support archiving + async archiveSession(sessionId: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + cached.isArchived.set(true, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true }; + this._agentHostService.dispatchAction(action, this._agentHostService.clientId, this._agentHostService.nextClientSeq()); + } } - async unarchiveSession(_sessionId: string): Promise { - // Agent host sessions don't support unarchiving + async unarchiveSession(sessionId: string): Promise { + const rawId = this._rawIdFromChatId(sessionId); + const cached = rawId ? this._sessionCache.get(rawId) : undefined; + if (cached && rawId) { + cached.isArchived.set(false, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false }; + this._agentHostService.dispatchAction(action, this._agentHostService.clientId, this._agentHostService.nextClientSeq()); + } } async deleteSession(sessionId: string): Promise { @@ -368,8 +403,10 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi setRead(sessionId: string, read: boolean): void { const rawId = this._rawIdFromChatId(sessionId); const cached = rawId ? this._sessionCache.get(rawId) : undefined; - if (cached) { + if (cached && rawId) { cached.isRead.set(read, undefined); + const action = { type: ActionType.SessionIsReadChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isRead: read }; + this._agentHostService.dispatchAction(action, this._agentHostService.clientId, this._agentHostService.nextClientSeq()); } } @@ -529,7 +566,7 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi } } - private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string }): void { + private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); if (this._sessionCache.has(rawId)) { @@ -545,6 +582,8 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi modifiedTime: summary.modifiedAt, summary: summary.title, workingDirectory: workingDir, + isRead: summary.isRead, + isDone: summary.isDone, }; const provider = AgentSession.provider(sessionUri) ?? DEFAULT_AGENT_PROVIDER; const cached = new LocalSessionAdapter(meta, this.id, sessionTypeForProvider(provider), this.sessionTypes[0].id); @@ -570,6 +609,24 @@ export class LocalAgentHostSessionsProvider extends Disposable implements ISessi } } + private _handleIsReadChanged(session: string, isRead: boolean): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.isRead.set(isRead, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + + private _handleIsDoneChanged(session: string, isDone: boolean): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.isArchived.set(isDone, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); + } + } + private _rawIdFromChatId(chatId: string): string | undefined { const prefix = `${this.id}:`; const resourceStr = chatId.startsWith(prefix) ? chatId.substring(prefix.length) : chatId; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index b43d5258dcac0..c827101fdbaf0 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -10,9 +10,9 @@ import { URI } from '../../../../base/common/uri.js'; import * as nls from '../../../../nls.js'; import { agentHostAuthority } from '../../../../platform/agentHost/common/agentHostUri.js'; import { type AgentProvider, type IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; -import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js'; -import { isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; +import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostEntry, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId, RemoteAgentHostsSettingId, getEntryAddress } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { TunnelAgentHostsSettingId } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionClientState } from '../../../../platform/agentHost/common/state/sessionClientState.js'; import { ROOT_STATE_URI, type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -153,7 +153,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc private _reconcileProviders(): void { const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); const entries = enabled ? this._remoteAgentHostService.configuredEntries : []; - const desiredAddresses = new Set(entries.map(e => e.address)); + const desiredAddresses = new Set(entries.map(e => getEntryAddress(e))); // Remove providers no longer configured for (const [address] of this._providerStores) { @@ -164,26 +164,28 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc // Add or recreate providers for configured entries for (const entry of entries) { - const existing = this._providerInstances.get(entry.address); - if (existing && existing.label !== (entry.name || entry.address)) { + const address = getEntryAddress(entry); + const existing = this._providerInstances.get(address); + if (existing && existing.label !== (entry.name || address)) { // Name changed — recreate since ISessionsProvider.label is readonly - this._providerStores.deleteAndDispose(entry.address); + this._providerStores.deleteAndDispose(address); } - if (!this._providerStores.has(entry.address)) { + if (!this._providerStores.has(address)) { this._createProvider(entry); } } } private _createProvider(entry: IRemoteAgentHostEntry): void { + const address = getEntryAddress(entry); const store = new DisposableStore(); const provider = this._instantiationService.createInstance( - RemoteAgentHostSessionsProvider, { address: entry.address, name: entry.name }); + RemoteAgentHostSessionsProvider, { address, name: entry.name }); store.add(provider); store.add(this._sessionsProvidersService.registerProvider(provider)); - this._providerInstances.set(entry.address, provider); - store.add(toDisposable(() => this._providerInstances.delete(entry.address))); - this._providerStores.set(entry.address, store); + this._providerInstances.set(address, provider); + store.add(toDisposable(() => this._providerInstances.delete(address))); + this._providerStores.set(address, store); } /** @@ -193,24 +195,26 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc private _reconnectSSHEntries(): void { const entries = this._remoteAgentHostService.configuredEntries; for (const entry of entries) { - if (!entry.sshConfigHost) { + if (entry.connection.type !== RemoteAgentHostEntryType.SSH || !entry.connection.sshConfigHost) { continue; } + const address = getEntryAddress(entry); + const sshConfigHost = entry.connection.sshConfigHost; // Skip if already connected or reconnecting const hasConnection = this._remoteAgentHostService.connections.some( - c => c.address === entry.address && c.status === RemoteAgentHostConnectionStatus.Connected + c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected ); - if (hasConnection || this._pendingSSHReconnects.has(entry.sshConfigHost)) { + if (hasConnection || this._pendingSSHReconnects.has(sshConfigHost)) { continue; } - this._pendingSSHReconnects.add(entry.sshConfigHost); - this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${entry.sshConfigHost}`); - this._sshService.reconnect(entry.sshConfigHost, entry.name).then(() => { - this._pendingSSHReconnects.delete(entry.sshConfigHost!); - this._logService.info(`[RemoteAgentHost] SSH tunnel re-established for ${entry.sshConfigHost}`); + this._pendingSSHReconnects.add(sshConfigHost); + this._logService.info(`[RemoteAgentHost] Re-establishing SSH tunnel for ${sshConfigHost}`); + this._sshService.reconnect(sshConfigHost, entry.name).then(() => { + this._pendingSSHReconnects.delete(sshConfigHost); + this._logService.info(`[RemoteAgentHost] SSH tunnel re-established for ${sshConfigHost}`); }).catch(err => { - this._pendingSSHReconnects.delete(entry.sshConfigHost!); - this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${entry.sshConfigHost}`, err); + this._pendingSSHReconnects.delete(sshConfigHost); + this._logService.error(`[RemoteAgentHost] SSH reconnect failed for ${sshConfigHost}`, err); }); } } @@ -276,11 +280,9 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc const authority = agentHostAuthority(address); store.add(this._agentHostFileSystemService.registerAuthority(authority, connection)); - // Forward non-session actions to client state + // Forward action envelopes to client state (both root and session) store.add(loggedConnection.onDidAction(envelope => { - if (!isSessionAction(envelope.action)) { - connState.clientState.receiveEnvelope(envelope); - } + connState.clientState.receiveEnvelope(envelope); })); // Forward notifications to client state @@ -304,9 +306,6 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc loggedConnection.logError('subscribe(root)', err); }); - // Authenticate with this new connection and refresh models afterward - this._authenticateWithConnection(loggedConnection).then(() => loggedConnection.refreshModels()).catch(() => { /* best-effort */ }); - // Wire connection to existing sessions provider this._providerInstances.get(address)?.setConnection(loggedConnection, connectionInfo.defaultDirectory); @@ -330,6 +329,10 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc } } + // Authenticate using protectedResources from agent info + this._authenticateWithConnection(loggedConnection, rootState.agents) + .catch(() => { /* best-effort */ }); + // Register new agents, push model updates to existing ones for (const agent of rootState.agents) { if (!connState.agents.has(agent.provider)) { @@ -425,10 +428,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc description: agent.description, connection: loggedConnection, connectionAuthority: sanitized, + clientState: connState.clientState, extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, - resolveAuthentication: () => this._resolveAuthenticationInteractively(loggedConnection), + resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(loggedConnection, resources), customizations, })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -491,26 +495,29 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc private _authenticateAllConnections(): void { for (const [, connState] of this._connections) { - this._authenticateWithConnection(connState.loggedConnection).then(() => connState.loggedConnection.refreshModels()).catch(() => { /* best-effort */ }); + const rootState = connState.clientState.rootState; + if (rootState) { + this._authenticateWithConnection(connState.loggedConnection, rootState.agents).catch(() => { /* best-effort */ }); + } } } /** - * Discover auth requirements from the connection's resource metadata - * and authenticate using matching tokens resolved via the standard - * VS Code authentication service (same flow as MCP auth). + * Authenticate using protectedResources from agent info in root state. + * Resolves tokens via the standard VS Code authentication service. */ - private async _authenticateWithConnection(loggedConnection: LoggingAgentConnection): Promise { + private async _authenticateWithConnection(loggedConnection: LoggingAgentConnection, agents: readonly IAgentInfo[]): Promise { try { - const metadata = await loggedConnection.getResourceMetadata(); - for (const resource of metadata.resources) { - const resourceUri = URI.parse(resource.resource); - const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); - if (token) { - this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); - await loggedConnection.authenticate({ resource: resource.resource, token }); - } else { - this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); + for (const agent of agents) { + for (const resource of agent.protectedResources ?? []) { + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); + await loggedConnection.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); + } } } } catch (err) { @@ -531,28 +538,36 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * Interactively prompt the user to authenticate when the server requires it. * Returns true if authentication succeeded. */ - private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection): Promise { + private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection, protectedResources: readonly IProtectedResourceMetadata[]): Promise { try { - const metadata = await loggedConnection.getResourceMetadata(); - for (const resource of metadata.resources) { + for (const resource of protectedResources) { for (const server of resource.authorization_servers ?? []) { const serverUri = URI.parse(server); const resourceUri = URI.parse(resource.resource); - const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); - if (!providerId) { - continue; + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + await loggedConnection.authenticate({ + resource: resource.resource, + token, + }); + } else { + const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); + if (!providerId) { + continue; + } + + const scopes = [...(resource.scopes_supported ?? [])]; + const session = await this._authenticationService.createSession(providerId, scopes, { + activateImmediate: true, + authorizationServer: serverUri, + }); + + await loggedConnection.authenticate({ + resource: resource.resource, + token: session.accessToken, + }); } - const scopes = [...(resource.scopes_supported ?? [])]; - const session = await this._authenticationService.createSession(providerId, scopes, { - activateImmediate: true, - authorizationServer: serverUri, - }); - - await loggedConnection.authenticate({ - resource: resource.resource, - token: session.accessToken, - }); this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`); return true; } @@ -600,5 +615,13 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + [TunnelAgentHostsSettingId]: { + type: 'array', + items: { type: 'string' }, + description: nls.localize('chat.remoteAgentTunnels', "Additional dev tunnel names to look for when connecting to remote agent hosts. These are looked up in addition to tunnels automatically enumerated from your account."), + default: [], + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, }, }); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts index 92bf637c25759..686e8fc0ff87d 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -5,13 +5,16 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IRemoteAgentHostService, parseRemoteAgentHostInput, RemoteAgentHostEntryType, RemoteAgentHostInputValidationError, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ISSHRemoteAgentHostService, SSHAuthMethod, type ISSHAgentHostConfig, type ISSHAgentHostConnection, type ISSHResolvedConfig } from '../../../../platform/agentHost/common/sshRemoteAgentHost.js'; +import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { SessionsCategories } from '../../../common/categories.js'; import { NewChatViewPane, SessionsViewId } from '../../chat/browser/newChatViewPane.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; @@ -76,9 +79,12 @@ registerAction2(class extends Action2 { // Connect try { await remoteAgentHostService.addRemoteAgentHost({ - address: parsed.parsed.address, name: name.trim(), connectionToken: parsed.parsed.connectionToken, + connection: { + type: RemoteAgentHostEntryType.WebSocket, + address: parsed.parsed.address, + }, }); } catch { notificationService.error(localize('addRemoteFailed', "Failed to connect to remote agent host {0}.", parsed.parsed.address)); @@ -468,3 +474,180 @@ registerAction2(class extends Action2 { await promptToConnectViaSSH(accessor); } }); + +// ---- Connect via Dev Tunnel ------------------------------------------------- + +interface ITunnelPickItem extends IQuickPickItem { + readonly tunnel: ITunnelInfo; +} + +interface IAuthProviderPickItem extends IQuickPickItem { + readonly provider: 'github' | 'microsoft'; +} + +async function promptToConnectViaTunnel( + accessor: ServicesAccessor, +): Promise { + const tunnelService = accessor.get(ITunnelAgentHostService); + const quickInputService = accessor.get(IQuickInputService); + const notificationService = accessor.get(INotificationService); + const authenticationService = accessor.get(IAuthenticationService); + const instantiationService = accessor.get(IInstantiationService); + const productService = accessor.get(IProductService); + + // Step 1: Determine auth provider — try cached sessions first, then prompt + let authProvider = await tunnelService.getAuthProvider({ silent: true }); + + if (!authProvider) { + // No cached session — prompt user to choose auth provider + const authPicks: IAuthProviderPickItem[] = [ + { + provider: 'github', + label: localize('tunnelAuthGitHub', "GitHub"), + description: localize('tunnelAuthGitHubDesc', "Sign in with your GitHub account"), + }, + { + provider: 'microsoft', + label: localize('tunnelAuthMicrosoft', "Microsoft Account"), + description: localize('tunnelAuthMicrosoftDesc', "Sign in with your Microsoft account"), + }, + ]; + + const authPicked = await quickInputService.pick(authPicks, { + title: localize('tunnelAuthTitle', "Sign In for Dev Tunnels"), + placeHolder: localize('tunnelAuthPlaceholder', "Choose an authentication provider"), + }); + if (!authPicked) { + return; + } + authProvider = authPicked.provider; + + // Trigger interactive auth for the chosen provider + const scopes = productService.tunnelApplicationConfig?.authenticationProviders?.[authProvider]?.scopes ?? []; + try { + await authenticationService.createSession(authProvider, scopes, { activateImmediate: true }); + } catch { + notificationService.error(localize('tunnelAuthFailed', "Authentication failed. Please try again.")); + return; + } + } + + // Step 2: Show tunnel picker immediately in busy state while enumerating + const tunnelPicker = quickInputService.createQuickPick(); + tunnelPicker.title = localize('tunnelPickTitle', "Connect via Dev Tunnel"); + tunnelPicker.placeholder = localize('tunnelPickPlaceholder', "Select a dev tunnel to connect to"); + tunnelPicker.busy = true; + tunnelPicker.show(); + + let tunnels: ITunnelInfo[]; + try { + tunnels = await tunnelService.listTunnels(); + } catch (err) { + tunnelPicker.dispose(); + notificationService.error(localize('tunnelListFailed', "Failed to list dev tunnels: {0}", err instanceof Error ? err.message : String(err))); + return; + } + + if (tunnels.length === 0) { + tunnelPicker.dispose(); + notificationService.info(localize('tunnelNoneFound', "No dev tunnels with agent host support were found. Start a tunnel with 'code tunnel' on another machine.")); + return; + } + + tunnelPicker.items = tunnels.map(t => ({ + label: t.name, + description: `${t.tunnelId} · protocol v${t.protocolVersion}`, + tunnel: t, + })); + tunnelPicker.busy = false; + + // Step 3: Wait for user selection + const picked = await new Promise(resolve => { + tunnelPicker.onDidAccept(() => { + resolve(tunnelPicker.selectedItems[0]); + tunnelPicker.dispose(); + }); + tunnelPicker.onDidHide(() => { + resolve(undefined); + tunnelPicker.dispose(); + }); + }); + if (!picked) { + return; + } + + // Step 4: Connect to the tunnel with progress notification + const handle = notificationService.notify({ + severity: Severity.Info, + message: localize('tunnelConnecting', "Connecting to tunnel '{0}'...", picked.tunnel.name), + progress: { infinite: true }, + }); + + try { + await tunnelService.connect(picked.tunnel, authProvider); + handle.close(); + } catch (err) { + handle.close(); + notificationService.error(localize('tunnelConnectFailed', "Failed to connect to tunnel '{0}': {1}", picked.tunnel.name, err instanceof Error ? err.message : String(err))); + return; + } + + // Cache the tunnel for future reconnections + tunnelService.cacheTunnel(picked.tunnel, authProvider); + + // Step 5: Open folder picker (same pattern as SSH) + await instantiationService.invokeFunction(accessor => promptForTunnelFolder(accessor, picked.tunnel)); +} + +/** + * After a successful tunnel connection, show the remote folder picker and + * pre-select the chosen folder in the workspace picker. + */ +async function promptForTunnelFolder( + accessor: ServicesAccessor, + tunnel: ITunnelInfo, +): Promise { + const viewsService = accessor.get(IViewsService); + const sessionsProvidersService = accessor.get(ISessionsProvidersService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + + const tunnelAddress = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; + + // The provider is created by TunnelAgentHostContribution when the + // tunnel is cached (via onDidChangeTunnels / _reconcileProviders). + const provider = sessionsProvidersService.getProviders().find(p => p.remoteAddress === tunnelAddress); + if (!provider) { + return; + } + + // Use the provider's existing browse action to show the folder picker + const browseAction = provider.browseActions[0]; + if (!browseAction) { + return; + } + + const workspace = await browseAction.run(); + if (!workspace) { + return; + } + + sessionsManagementService.openNewSessionView(); + const view = await viewsService.openView(SessionsViewId, true); + view?.selectWorkspace({ providerId: provider.id, workspace }); +} + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.sessions.connectViaTunnel', + title: localize2('connectViaTunnel', "Connect to Remote Agent Host via Dev Tunnel"), + category: SessionsCategories.Sessions, + f1: true, + precondition: ContextKeyExpr.equals(`config.${RemoteAgentHostsEnabledSettingId}`, true), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + await promptToConnectViaTunnel(accessor); + } +}); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 52da85bc5b1ae..2431ddfd2aad0 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -22,7 +22,6 @@ import { RemoteAgentHostConnectionStatus } from '../../../../platform/agentHost/ import { ActionType, isSessionAction } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { IFileDialogService } from '../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; import { IChatSendRequestOptions, IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange, IChatSessionsService } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; @@ -78,6 +77,8 @@ interface IChatData { export interface IRemoteAgentHostSessionsProviderConfig { readonly address: string; readonly name: string; + /** Optional hook to establish a connection on demand (e.g. tunnel relay). */ + readonly connectOnDemand?: () => Promise; } /** @@ -130,12 +131,25 @@ class RemoteSessionAdapter implements IChatData { this.workspace = observableValue('workspace', metadata.workingDirectory ? RemoteAgentHostSessionsProvider.buildWorkspace(metadata.workingDirectory, providerLabel, connectionAuthority) : undefined); + + if (metadata.isRead === false) { + this.isRead.set(false, undefined); + } + if (metadata.isDone) { + this.isArchived.set(true, undefined); + } } update(metadata: IAgentSessionMetadata): void { this.title.set(metadata.summary ?? this.title.get(), undefined); this.updatedAt.set(new Date(metadata.modifiedTime), undefined); this.lastTurnEnd.set(metadata.modifiedTime ? new Date(metadata.modifiedTime) : undefined, undefined); + if (metadata.isRead !== undefined) { + this.isRead.set(metadata.isRead, undefined); + } + if (metadata.isDone !== undefined) { + this.isArchived.set(metadata.isDone, undefined); + } } } @@ -200,6 +214,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess private readonly _connectionListeners = this._register(new DisposableStore()); private readonly _onDidDisconnect = this._register(new Emitter()); private readonly _connectionAuthority: string; + private readonly _connectOnDemand: (() => Promise) | undefined; constructor( config: IRemoteAgentHostSessionsProviderConfig, @@ -209,11 +224,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @INotificationService private readonly _notificationService: INotificationService, - @IStorageService private readonly _storageService: IStorageService, ) { super(); this._connectionAuthority = agentHostAuthority(config.address); + this._connectOnDemand = config.connectOnDemand; const displayName = config.name || config.address; this.id = `agenthost-${this._connectionAuthority}`; @@ -275,6 +290,10 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess this._refreshSessions(cts.token).finally(() => cts.dispose()); } else if (e.action.type === ActionType.SessionTitleChanged && isSessionAction(e.action)) { this._handleTitleChanged(e.action.session, e.action.title); + } else if (e.action.type === ActionType.SessionIsReadChanged && isSessionAction(e.action)) { + this._handleIsReadChanged(e.action.session, e.action.isRead); + } else if (e.action.type === ActionType.SessionIsDoneChanged && isSessionAction(e.action)) { + this._handleIsDoneChanged(e.action.session, e.action.isDone); } })); @@ -413,8 +432,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { cached.isArchived.set(true, undefined); - this._storeArchivedState(rawId, true); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + if (this._connection) { + const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: true }; + this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq()); + } } } @@ -423,8 +445,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached && rawId) { cached.isArchived.set(false, undefined); - this._storeArchivedState(rawId, false); this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); + if (this._connection) { + const action = { type: ActionType.SessionIsDoneChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isDone: false }; + this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq()); + } } } @@ -434,7 +459,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess if (cached && rawId && this._connection) { await this._connection.disposeSession(AgentSession.uri(cached.agentProvider, rawId)); this._sessionCache.delete(rawId); - this._storeArchivedState(rawId, false); this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); } } @@ -459,6 +483,10 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess const cached = rawId ? this._sessionCache.get(rawId) : undefined; if (cached) { cached.isRead.set(read, undefined); + if (this._connection && rawId) { + const action = { type: ActionType.SessionIsReadChanged as const, session: AgentSession.uri(cached.agentProvider, rawId).toString(), isRead: read }; + this._connection.dispatchAction(action, this._connection.clientId, this._connection.nextClientSeq()); + } } } @@ -588,7 +616,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess changed.push(this._chatToSession(existing)); } else { const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); - this._restoreArchivedState(rawId, cached); this._sessionCache.set(rawId, cached); added.push(this._chatToSession(cached)); } @@ -602,9 +629,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } - // Prune archived IDs that no longer exist on the server - this._pruneArchivedIds(currentKeys); - if (added.length > 0 || removed.length > 0 || changed.length > 0) { this._onDidChangeSessions.fire({ added, removed, changed }); } @@ -649,7 +673,7 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } - private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string }): void { + private _handleSessionAdded(summary: { resource: string; provider: string; title: string; createdAt: number; modifiedAt: number; workingDirectory?: string; isRead?: boolean; isDone?: boolean }): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); if (this._sessionCache.has(rawId)) { @@ -666,9 +690,10 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess modifiedTime: summary.modifiedAt, summary: summary.title, workingDirectory: workingDir, + isRead: summary.isRead, + isDone: summary.isDone, }; const cached = new RemoteSessionAdapter(meta, this.id, this._sessionTypeForProvider(provider), this.sessionTypes[0].id, this.label, this._connectionAuthority); - this._restoreArchivedState(rawId, cached); this._sessionCache.set(rawId, cached); this._onDidChangeSessions.fire({ added: [this._chatToSession(cached)], removed: [], changed: [] }); } @@ -678,7 +703,6 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess const cached = this._sessionCache.get(rawId); if (cached) { this._sessionCache.delete(rawId); - this._storeArchivedState(rawId, false); this._onDidChangeSessions.fire({ added: [], removed: [this._chatToSession(cached)], changed: [] }); } } @@ -692,60 +716,21 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess } } - // -- Private: Archived State Persistence -- - - private get _archivedStorageKey(): string { - return `remoteAgentHost.archivedSessions.${this.id}`; - } - - private _loadArchivedIds(): Set { - const raw = this._storageService.get(this._archivedStorageKey, StorageScope.PROFILE); - if (!raw) { - return new Set(); - } - try { - const parsed = JSON.parse(raw); - return new Set(Array.isArray(parsed) ? parsed : []); - } catch { - return new Set(); - } - } - - private _storeArchivedState(rawId: string, archived: boolean): void { - const ids = this._loadArchivedIds(); - if (archived) { - ids.add(rawId); - } else { - ids.delete(rawId); - } - this._storageService.store(this._archivedStorageKey, JSON.stringify([...ids]), StorageScope.PROFILE, StorageTarget.USER); - } - - private _restoreArchivedState(rawId: string, session: RemoteSessionAdapter): void { - if (this._loadArchivedIds().has(rawId)) { - session.isArchived.set(true, undefined); + private _handleIsReadChanged(session: string, isRead: boolean): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.isRead.set(isRead, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); } } - /** - * Remove archived IDs that are no longer present on the server. - * Called after a full refresh to prevent unbounded growth of stored IDs. - */ - private _pruneArchivedIds(validIds: Set): void { - const archivedIds = this._loadArchivedIds(); - let changed = false; - for (const id of archivedIds) { - if (!validIds.has(id)) { - archivedIds.delete(id); - changed = true; - } - } - if (changed) { - if (archivedIds.size === 0) { - this._storageService.remove(this._archivedStorageKey, StorageScope.PROFILE); - } else { - this._storageService.store(this._archivedStorageKey, JSON.stringify([...archivedIds]), StorageScope.PROFILE, StorageTarget.USER); - } + private _handleIsDoneChanged(session: string, isDone: boolean): void { + const rawId = AgentSession.id(session); + const cached = this._sessionCache.get(rawId); + if (cached) { + cached.isArchived.set(isDone, undefined); + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [this._chatToSession(cached)] }); } } @@ -766,6 +751,11 @@ export class RemoteAgentHostSessionsProvider extends Disposable implements ISess // -- Private: Browse -- private async _browseForFolder(): Promise { + // Establish connection on demand if a hook is provided (e.g. tunnel relay) + if (!this._connection && this._connectOnDemand) { + await this._connectOnDemand(); + } + if (!this._connection) { this._notificationService.error(localize('notConnected', "Unable to connect to remote agent host '{0}'.", this.label)); return undefined; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts new file mode 100644 index 0000000000000..6e82bb9ee6064 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import * as nls from '../../../../nls.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, type ITunnelInfo } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; + +/** Minimum interval between silent status checks (5 minutes). */ +const STATUS_CHECK_INTERVAL = 5 * 60 * 1000; + +export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'sessions.contrib.tunnelAgentHostContribution'; + + private readonly _providerStores = this._register(new DisposableMap()); + private readonly _providerInstances = new Map(); + private readonly _pendingConnects = new Map>(); + private _lastStatusCheck = 0; + + constructor( + @ITunnelAgentHostService private readonly _tunnelService: ITunnelAgentHostService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @INotificationService private readonly _notificationService: INotificationService, + ) { + super(); + + // Create providers for cached tunnels + this._reconcileProviders(); + + // Update connection statuses when connections change + this._register(this._remoteAgentHostService.onDidChangeConnections(() => { + this._updateConnectionStatuses(); + this._wireConnections(); + })); + + // Reconcile providers when the tunnel cache changes + this._register(this._tunnelService.onDidChangeTunnels(() => { + this._reconcileProviders(); + })); + + // Silently check status of cached tunnels on startup + this._silentStatusCheck(); + } + + /** + * Called by the workspace picker when it opens. Silently re-checks + * tunnel statuses if more than 5 minutes have elapsed since the last check. + */ + async checkTunnelStatuses(): Promise { + if (Date.now() - this._lastStatusCheck < STATUS_CHECK_INTERVAL) { + return; + } + await this._silentStatusCheck(); + } + + // -- Provider management -- + + private _reconcileProviders(): void { + const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); + const cached = enabled ? this._tunnelService.getCachedTunnels() : []; + const desiredAddresses = new Set(cached.map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`)); + + // Remove providers no longer cached + for (const [address] of this._providerStores) { + if (!desiredAddresses.has(address)) { + this._providerStores.deleteAndDispose(address); + this._providerInstances.delete(address); + } + } + + // Add providers for cached tunnels + for (const tunnel of cached) { + const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; + if (!this._providerStores.has(address)) { + this._createProvider(address, tunnel.name); + } + } + } + + private _createProvider(address: string, name: string): void { + const store = new DisposableStore(); + const provider = this._instantiationService.createInstance( + RemoteAgentHostSessionsProvider, { + address, + name, + connectOnDemand: () => this._connectTunnel(address), + }, + ); + store.add(provider); + store.add(this._sessionsProvidersService.registerProvider(provider)); + this._providerInstances.set(address, provider); + store.add(toDisposable(() => this._providerInstances.delete(address))); + this._providerStores.set(address, store); + } + + // -- Connection status -- + + private _updateConnectionStatuses(): void { + for (const [address, provider] of this._providerInstances) { + const connectionInfo = this._remoteAgentHostService.connections.find(c => c.address === address); + if (connectionInfo) { + provider.setConnectionStatus(connectionInfo.status); + } else if (this._pendingConnects.has(address)) { + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connecting); + } else { + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); + } + } + } + + /** + * Wire live connections to their providers so session operations work. + */ + private _wireConnections(): void { + for (const [address, provider] of this._providerInstances) { + const connectionInfo = this._remoteAgentHostService.connections.find( + c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected + ); + if (connectionInfo) { + const connection = this._remoteAgentHostService.getConnection(address); + if (connection) { + provider.setConnection(connection, connectionInfo.defaultDirectory); + } + } + } + } + + // -- On-demand connection -- + + /** + * Establish a relay connection to a cached tunnel. Called on demand + * when the user invokes the browse action on an online-but-not-connected tunnel. + */ + private _connectTunnel(address: string): Promise { + const existing = this._pendingConnects.get(address); + if (existing) { + return existing; + } + + const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length); + const cached = this._tunnelService.getCachedTunnels().find(t => t.tunnelId === tunnelId); + if (!cached) { + return Promise.resolve(); + } + + const promise = (async () => { + // Show a progress notification after a short delay so quick + // connects don't flash a notification. + let handle: { close(): void } | undefined; + const timer = setTimeout(() => { + handle = this._notificationService.notify({ + severity: Severity.Info, + message: nls.localize('tunnelConnecting', "Connecting to tunnel '{0}'...", cached.name), + progress: { infinite: true }, + }); + }, 1000); + + this._updateConnectionStatuses(); + try { + const tunnelInfo: ITunnelInfo = { + tunnelId: cached.tunnelId, + clusterId: cached.clusterId, + name: cached.name, + tags: [], + protocolVersion: 5, + hostConnectionCount: 0, + }; + await this._tunnelService.connect(tunnelInfo, cached.authProvider); + } finally { + clearTimeout(timer); + handle?.close(); + this._pendingConnects.delete(address); + this._updateConnectionStatuses(); + } + })(); + + this._pendingConnects.set(address, promise); + return promise; + } + + // -- Silent status check -- + + private async _silentStatusCheck(): Promise { + const enabled = this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); + if (!enabled) { + return; + } + + this._lastStatusCheck = Date.now(); + + // Fetch tunnel list silently to check online status + let onlineTunnels: ITunnelInfo[] | undefined; + try { + onlineTunnels = await this._tunnelService.listTunnels({ silent: true }); + } catch { + // No cached token or network error — leave statuses as-is + return; + } + + const cached = this._tunnelService.getCachedTunnels(); + if (onlineTunnels) { + const onlineIds = new Set(onlineTunnels.map(t => t.tunnelId)); + // Remove cached tunnels that no longer exist on the account + for (const tunnel of cached) { + if (!onlineIds.has(tunnel.tunnelId)) { + this._tunnelService.removeCachedTunnel(tunnel.tunnelId); + } + } + + // Update online/offline status based on hostConnectionCount. + // For tunnels, Connected means "host is online" (clickable to connect), + // Disconnected means "host is offline". Actual relay connection + // establishment happens when the user clicks the tunnel. + const onlineTunnelMap = new Map(onlineTunnels.map(t => [t.tunnelId, t])); + for (const [address, provider] of this._providerInstances) { + // Skip tunnels that already have an active relay connection + const hasConnection = this._remoteAgentHostService.connections.some( + c => c.address === address && c.status === RemoteAgentHostConnectionStatus.Connected + ); + if (hasConnection) { + continue; + } + + const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length); + const info = onlineTunnelMap.get(tunnelId); + if (info && info.hostConnectionCount > 0) { + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Connected); + } else { + provider.setConnectionStatus(RemoteAgentHostConnectionStatus.Disconnected); + } + } + } + } +} + +registerWorkbenchContribution2(TunnelAgentHostContribution.ID, TunnelAgentHostContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostService.ts b/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostService.ts new file mode 100644 index 0000000000000..cc04149ffb985 --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostService.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { ITunnelAgentHostService } from '../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { TunnelAgentHostService } from './tunnelAgentHostServiceImpl.js'; + +registerSingleton(ITunnelAgentHostService, TunnelAgentHostService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts new file mode 100644 index 0000000000000..a6520a817cd0d --- /dev/null +++ b/src/vs/sessions/contrib/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ISharedProcessService } from '../../../../platform/ipc/electron-browser/services.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IRemoteAgentHostService, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { + ITunnelAgentHostService, + TUNNEL_AGENT_HOST_CHANNEL, + TunnelAgentHostsSettingId, + type ICachedTunnel, + type ITunnelAgentHostMainService, + type ITunnelInfo, +} from '../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/electron-browser/remoteAgentHostProtocolClient.js'; +import { TunnelRelayTransport } from '../../../../platform/agentHost/electron-browser/tunnelRelayTransport.js'; + +const LOG_PREFIX = '[TunnelAgentHost]'; + +/** Storage key for recently used tunnel cache. */ +const CACHED_TUNNELS_KEY = 'tunnelAgentHost.recentTunnels'; + +/** + * Renderer-side implementation of {@link ITunnelAgentHostService} that + * delegates tunnel SDK operations to the shared process via IPC, then + * registers connections with the renderer-local {@link IRemoteAgentHostService}. + */ +export class TunnelAgentHostService extends Disposable implements ITunnelAgentHostService { + declare readonly _serviceBrand: undefined; + + private readonly _mainService: ITunnelAgentHostMainService; + + private readonly _onDidChangeTunnels = this._register(new Emitter()); + readonly onDidChangeTunnels: Event = this._onDidChangeTunnels.event; + + /** Tracks which auth provider was last used successfully. */ + private _lastAuthProvider: 'github' | 'microsoft' | undefined; + + constructor( + @ISharedProcessService sharedProcessService: ISharedProcessService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IProductService private readonly _productService: IProductService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + + this._mainService = ProxyChannel.toService( + sharedProcessService.getChannel(TUNNEL_AGENT_HOST_CHANNEL), + ); + } + + async listTunnels(options?: { silent?: boolean }): Promise { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + return []; + } + + const silent = options?.silent ?? false; + const auth = await this._getToken(silent); + if (!auth) { + if (silent) { + this._logService.debug(`${LOG_PREFIX} No cached token available for silent tunnel enumeration`); + } else { + this._logService.warn(`${LOG_PREFIX} No auth token available for tunnel enumeration`); + } + return []; + } + + const additionalNames = this._configurationService.getValue(TunnelAgentHostsSettingId) ?? []; + return this._mainService.listTunnels(auth.token, auth.provider, additionalNames.length > 0 ? additionalNames : undefined); + } + + async connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): Promise { + const auth = authProvider + ? await this._getTokenForProvider(authProvider, false) + : await this._getToken(false); + if (!auth) { + throw new Error('No authentication available'); + } + + this._logService.info(`${LOG_PREFIX} Connecting to tunnel '${tunnel.name}' (${tunnel.tunnelId})`); + const result = await this._mainService.connect(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId); + this._logService.info(`${LOG_PREFIX} Tunnel relay connected, connectionId=${result.connectionId}`); + + // Create relay transport + protocol client, then register with RemoteAgentHostService + try { + const transport = new TunnelRelayTransport(result.connectionId, this._mainService); + const protocolClient = this._instantiationService.createInstance( + RemoteAgentHostProtocolClient, result.address, transport, + ); + + await protocolClient.connect(); + this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${result.address}`); + + await this._remoteAgentHostService.addSSHConnection({ + name: result.name, + connectionToken: result.connectionToken, + connection: { + type: RemoteAgentHostEntryType.Tunnel, + tunnelId: tunnel.tunnelId, + clusterId: tunnel.clusterId, + authProvider: auth.provider, + }, + }, protocolClient); + + this._onDidChangeTunnels.fire(); + } catch (err) { + this._logService.error(`${LOG_PREFIX} Connection setup failed`, err); + this._mainService.disconnect(result.connectionId).catch(() => { /* best effort */ }); + throw err; + } + } + + async disconnect(address: string): Promise { + await this._remoteAgentHostService.removeRemoteAgentHost(address); + this._onDidChangeTunnels.fire(); + } + + /** + * Get an auth token, trying cached sessions first (silent), + * then prompting interactively if `silent` is false. + */ + private async _getToken(silent: boolean): Promise<{ token: string; provider: 'github' | 'microsoft' } | undefined> { + // Try the last known provider first + if (this._lastAuthProvider) { + const result = await this._getTokenForProvider(this._lastAuthProvider, silent); + if (result) { + return result; + } + } + + // Try both providers silently + for (const provider of ['github', 'microsoft'] as const) { + if (provider === this._lastAuthProvider) { + continue; // Already tried above + } + const result = await this._getTokenForProvider(provider, true); + if (result) { + return result; + } + } + + // If not silent, we would need the caller to prompt for provider selection. + // Return undefined — the caller (promptToConnectViaTunnel) handles the interactive flow. + return undefined; + } + + /** + * Get a token for a specific auth provider. + * @param provider The auth provider to use. + * @param silent If true, only try cached sessions. If false, prompt the user. + */ + private _getScopesForProvider(provider: 'github' | 'microsoft'): string[] { + const config = this._productService.tunnelApplicationConfig?.authenticationProviders; + return config?.[provider]?.scopes ?? []; + } + + private async _getTokenForProvider( + provider: 'github' | 'microsoft', + silent: boolean, + ): Promise<{ token: string; provider: 'github' | 'microsoft' } | undefined> { + const providerId = provider; + const scopes = this._getScopesForProvider(provider); + if (scopes.length === 0) { + return undefined; + } + + try { + // Try exact scope match first + let sessions = await this._authenticationService.getSessions(providerId, scopes, {}, true); + + // Fall back: find any session whose scopes are a superset + if (sessions.length === 0) { + const allSessions = await this._authenticationService.getSessions(providerId, undefined, {}, true); + const requestedSet = new Set(scopes); + let bestSession: typeof allSessions[number] | undefined; + let bestExtra = Infinity; + for (const session of allSessions) { + const sessionScopes = new Set(session.scopes); + let isSuperset = true; + for (const scope of requestedSet) { + if (!sessionScopes.has(scope)) { + isSuperset = false; + break; + } + } + if (isSuperset) { + const extra = sessionScopes.size - requestedSet.size; + if (extra < bestExtra) { + bestExtra = extra; + bestSession = session; + } + } + } + if (bestSession) { + sessions = [bestSession]; + } + } + + // Interactive fallback: create a new session + if (sessions.length === 0 && !silent) { + const session = await this._authenticationService.createSession(providerId, scopes, { activateImmediate: true }); + sessions = [session]; + } + + if (sessions.length > 0) { + const token = sessions[0].accessToken; + if (token) { + this._lastAuthProvider = provider; + return { token, provider }; + } + } + } catch (err) { + this._logService.debug(`${LOG_PREFIX} Failed to get ${provider} token: ${err}`); + } + return undefined; + } + + async getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined> { + const result = await this._getToken(options?.silent ?? true); + return result?.provider; + } + + getCachedTunnels(): ICachedTunnel[] { + const raw = this._storageService.get(CACHED_TUNNELS_KEY, StorageScope.APPLICATION); + if (!raw) { + return []; + } + try { + return JSON.parse(raw); + } catch { + return []; + } + } + + cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void { + const cached = this.getCachedTunnels(); + const filtered = cached.filter(t => t.tunnelId !== tunnel.tunnelId); + filtered.unshift({ + tunnelId: tunnel.tunnelId, + clusterId: tunnel.clusterId, + name: tunnel.name, + authProvider, + }); + this._storeCachedTunnels(filtered.slice(0, 20)); + this._onDidChangeTunnels.fire(); + } + + removeCachedTunnel(tunnelId: string): void { + const cached = this.getCachedTunnels(); + this._storeCachedTunnels(cached.filter(t => t.tunnelId !== tunnelId)); + this._onDidChangeTunnels.fire(); + } + + private _storeCachedTunnels(tunnels: ICachedTunnel[]): void { + if (tunnels.length === 0) { + this._storageService.remove(CACHED_TUNNELS_KEY, StorageScope.APPLICATION); + } else { + this._storageService.store(CACHED_TUNNELS_KEY, JSON.stringify(tunnels), StorageScope.APPLICATION, StorageTarget.USER); + } + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index 1c8a2cd0be111..892f4703b8b52 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -8,16 +8,10 @@ height: 100%; min-height: 0; - .pane-body & .monaco-scrollable-element { - padding: 0 10px; - } - - .pane-body & .monaco-tree-sticky-container { - padding: 0 10px; - } - - .monaco-list-row { + .monaco-list-row:has(.session-item) { border-radius: 6px; + margin: 0 10px; + width: calc(100% - 20px); } .monaco-list-row .force-no-twistie { @@ -281,7 +275,7 @@ display: flex; justify-content: center; align-items: center; - padding: 0 6px; + padding: 0 10px; font-size: 11px; color: var(--vscode-descriptionForeground); min-height: 26px; @@ -316,8 +310,8 @@ font-weight: 500; color: var(--vscode-descriptionForeground); text-transform: uppercase; - /* align with session item padding */ - padding: 0 6px; + /* align with session item margin */ + padding: 0 10px; .session-section-label { flex: 0 1 auto; diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts index 6c02bba2306ab..bf32495f506f1 100644 --- a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -19,8 +19,16 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IWorkbenchEnvironmentService } from '../../../../workbench/services/environment/common/environmentService.js'; import { SessionsWalkthroughOverlay, WalkthroughOutcome } from './sessionsWalkthrough.js'; +import { WELCOME_COMPLETE_KEY } from '../../../common/welcome.js'; -const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; +function shouldSkipSessionsWelcome(environmentService: IWorkbenchEnvironmentService): boolean { + const envArgs = (environmentService as IWorkbenchEnvironmentService & { args?: Record }).args; + if (envArgs?.['skip-sessions-welcome']) { + return true; + } + + return typeof globalThis.location !== 'undefined' && new URLSearchParams(globalThis.location.search).has('skip-sessions-welcome'); +} function needsChatSetup(chatEntitlementService: Pick, includeUnknown: boolean = true): boolean { const { sentiment, entitlement } = chatEntitlementService; @@ -39,6 +47,57 @@ function needsChatSetup(chatEntitlementService: Pick): boolean { return outcome === 'completed' || !needsChatSetup(chatEntitlementService); } + +export function resetSessionsWelcome( + storageService: Pick, + instantiationService: IInstantiationService, + layoutService: IWorkbenchLayoutService, + chatEntitlementService: Pick, + contextKeyService: IContextKeyService, + environmentService: IWorkbenchEnvironmentService, + logService: ILogService, +): void { + // Clear completion marker + storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); + + if (shouldSkipSessionsWelcome(environmentService)) { + return; + } + + // Immediately show the walkthrough overlay + const store = new DisposableStore(); + const welcomeVisibleKey = SessionsWelcomeVisibleContext.bindTo(contextKeyService); + welcomeVisibleKey.set(true); + store.add(toDisposable(() => welcomeVisibleKey.reset())); + + const walkthrough = store.add(instantiationService.createInstance( + SessionsWalkthroughOverlay, + layoutService.mainContainer, + )); + + store.add(autorun(reader => { + chatEntitlementService.sentimentObs.read(reader); + chatEntitlementService.entitlementObs.read(reader); + + if (!needsChatSetup(chatEntitlementService)) { + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + walkthrough.complete(); + store.dispose(); + } + })); + + walkthrough.outcome + .then(outcome => { + logService.info(`[sessions welcome] Developer reset walkthrough finished with outcome: ${outcome}`); + if (shouldPersistWelcomeCompletion(outcome, chatEntitlementService)) { + storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + }) + .finally(() => { + store.dispose(); + }); +} + export class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsWelcome'; @@ -65,11 +124,7 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc // Allow automated tests to skip the welcome overlay entirely. // Desktop: --skip-sessions-welcome CLI flag // Web: ?skip-sessions-welcome query parameter - const envArgs = (this.environmentService as IWorkbenchEnvironmentService & { args?: Record }).args; - if (envArgs?.['skip-sessions-welcome']) { - return; - } - if (typeof globalThis.location !== 'undefined' && new URLSearchParams(globalThis.location.search).has('skip-sessions-welcome')) { + if (shouldSkipSessionsWelcome(this.environmentService)) { return; } @@ -94,10 +149,11 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc * completed. If the user's state changes such that setup is needed again * (e.g. extension uninstalled/disabled), shows the welcome overlay. * - * {@link ChatEntitlement.Unknown} is intentionally ignored here: it is - * almost always a transient state caused by a stale OAuth token being - * refreshed after an update. A genuine sign-out will be caught on the - * next app launch via the initial {@link showWalkthroughIfNeeded} check. + * {@link ChatEntitlement.Unknown} is intentionally ignored here while the + * welcome completion marker remains set: it is almost always a transient + * state caused by a stale OAuth token being refreshed after an update. + * Explicit sign-out clears that marker first so the next Unknown transition + * immediately returns the user to the sign-in walkthrough. */ private watchEntitlementState(): void { let setupComplete = !this._needsChatSetup(false); @@ -105,7 +161,8 @@ export class SessionsWelcomeContribution extends Disposable implements IWorkbenc this.chatEntitlementService.sentimentObs.read(reader); this.chatEntitlementService.entitlementObs.read(reader); - const needsSetup = this._needsChatSetup(false); + const includeUnknown = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); + const needsSetup = this._needsChatSetup(includeUnknown); if (setupComplete && needsSetup) { this.showWalkthrough(); } @@ -179,42 +236,8 @@ registerAction2(class extends Action2 { const layoutService = accessor.get(IWorkbenchLayoutService); const chatEntitlementService = accessor.get(IChatEntitlementService); const contextKeyService = accessor.get(IContextKeyService); + const environmentService = accessor.get(IWorkbenchEnvironmentService); const logService = accessor.get(ILogService); - - // Clear completion marker - storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); - - // Immediately show the walkthrough overlay - const store = new DisposableStore(); - const welcomeVisibleKey = SessionsWelcomeVisibleContext.bindTo(contextKeyService); - welcomeVisibleKey.set(true); - store.add(toDisposable(() => welcomeVisibleKey.reset())); - - const walkthrough = store.add(instantiationService.createInstance( - SessionsWalkthroughOverlay, - layoutService.mainContainer, - )); - - store.add(autorun(reader => { - chatEntitlementService.sentimentObs.read(reader); - chatEntitlementService.entitlementObs.read(reader); - - if (!needsChatSetup(chatEntitlementService)) { - storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - walkthrough.complete(); - store.dispose(); - } - })); - - walkthrough.outcome - .then(outcome => { - logService.info(`[sessions welcome] Developer reset walkthrough finished with outcome: ${outcome}`); - if (shouldPersistWelcomeCompletion(outcome, chatEntitlementService)) { - storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); - } - }) - .finally(() => { - store.dispose(); - }); + resetSessionsWelcome(storageService, instantiationService, layoutService, chatEntitlementService, contextKeyService, environmentService, logService); } }); diff --git a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts index bf725adf999d0..5a480efb618aa 100644 --- a/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts +++ b/src/vs/sessions/contrib/welcome/test/browser/welcome.contribution.test.ts @@ -17,14 +17,15 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IExtensionService } from '../../../../../workbench/services/extensions/common/extensions.js'; import { ChatEntitlement, IChatEntitlementService, IChatSentiment } from '../../../../../workbench/services/chat/common/chatEntitlementService.js'; import { ChatSetupStrategy } from '../../../../../workbench/contrib/chat/browser/chatSetup/chatSetup.js'; +import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js'; +import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { workbenchInstantiationService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { SessionsWelcomeVisibleContext } from '../../../../common/contextkeys.js'; -import { SessionsWelcomeContribution } from '../../browser/welcome.contribution.js'; +import { WELCOME_COMPLETE_KEY } from '../../../../common/welcome.js'; +import { resetSessionsWelcome, SessionsWelcomeContribution } from '../../browser/welcome.contribution.js'; import { SessionsWalkthroughOverlay, WalkthroughOutcome } from '../../browser/sessionsWalkthrough.js'; -const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; - class MockChatEntitlementService implements Partial { declare readonly _serviceBrand: undefined; @@ -150,6 +151,52 @@ suite('SessionsWelcomeContribution', () => { assert.strictEqual(isOverlayVisible(), false, 'should remain hidden after recovery'); }); + test('returning user: sign out clears welcome completion and shows overlay on Unknown entitlement', async () => { + markReturningUser(); + mockEntitlementService.entitlementObs.set(ChatEntitlement.Pro, undefined); + mockEntitlementService.sentimentObs.set({ completed: true, installed: true } as IChatSentiment, undefined); + + const contribution = disposables.add(instantiationService.createInstance(SessionsWelcomeContribution)); + assert.ok(contribution); + assert.strictEqual(isOverlayVisible(), false, 'should not show initially'); + + const storageService = instantiationService.get(IStorageService); + storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); + + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Unknown, tx); + }); + + assert.strictEqual(isOverlayVisible(), true, 'should show overlay after explicit sign out'); + + transaction(tx => { + mockEntitlementService.entitlementObs.set(ChatEntitlement.Free, tx); + }); + await flushMicrotasks(); + + assert.strictEqual(isOverlayVisible(), false, 'should hide overlay after signing back in'); + assert.strictEqual(storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false), true); + }); + + test('reset welcome respects skip-sessions-welcome while still clearing completion state', async () => { + markReturningUser(); + + const storageService = instantiationService.get(IStorageService); + const layoutService = instantiationService.get(IWorkbenchLayoutService); + const contextKeyService = instantiationService.get(IContextKeyService); + const logService = instantiationService.get(ILogService); + const environmentService = instantiationService.get(IWorkbenchEnvironmentService); + instantiationService.stub(IWorkbenchEnvironmentService, { + ...environmentService, + args: { ...(environmentService as IWorkbenchEnvironmentService & { args?: Record }).args, 'skip-sessions-welcome': true }, + } as IWorkbenchEnvironmentService); + + resetSessionsWelcome(storageService, instantiationService, layoutService, mockEntitlementService, contextKeyService, instantiationService.get(IWorkbenchEnvironmentService), logService); + + assert.strictEqual(storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false), false, 'should clear completion state'); + assert.strictEqual(isOverlayVisible(), false, 'should not show overlay when skip flag is set'); + }); + test('returning user: transient Unresolved entitlement does NOT show overlay', () => { markReturningUser(); mockEntitlementService.entitlementObs.set(ChatEntitlement.Pro, undefined); diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index 1a5a2e6e18dc6..f1ee498e9ec50 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -240,6 +240,10 @@ class SessionsManagementService extends Disposable implements ISessionsManagemen await this.chatWidgetService.openSession(sessionData.resource, ChatViewPaneTarget, { preserveFocus: options?.preserveFocus }); } + unsetNewSession(): void { + this.setActiveSession(undefined); + } + createNewSession(providerId: string, workspace: ISessionWorkspace): ISession { if (!this.isNewChatSessionContext.get()) { this.isNewChatSessionContext.set(true); diff --git a/src/vs/sessions/services/sessions/browser/sessionsProvidersService.ts b/src/vs/sessions/services/sessions/browser/sessionsProvidersService.ts index caa4aa345da5c..177fc35b4e256 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsProvidersService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsProvidersService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { ISessionsProvider } from '../common/sessionsProvider.js'; @@ -28,7 +28,7 @@ export interface ISessionsProvidersService { class SessionsProvidersService extends Disposable implements ISessionsProvidersService { declare readonly _serviceBrand: undefined; - private readonly _providers = new Map(); + private readonly _providers = new Map(); private readonly _onDidChangeProviders = this._register(new Emitter()); readonly onDidChangeProviders: Event = this._onDidChangeProviders.event; @@ -38,15 +38,12 @@ class SessionsProvidersService extends Disposable implements ISessionsProvidersS throw new Error(`Sessions provider '${provider.id}' is already registered.`); } - const disposables = new DisposableStore(); - - this._providers.set(provider.id, { provider, disposables }); + this._providers.set(provider.id, provider); this._onDidChangeProviders.fire({ added: [provider], removed: [] }); return toDisposable(() => { const entry = this._providers.get(provider.id); if (entry) { - entry.disposables.dispose(); this._providers.delete(provider.id); this._onDidChangeProviders.fire({ added: [], removed: [provider] }); } @@ -54,11 +51,11 @@ class SessionsProvidersService extends Disposable implements ISessionsProvidersS } getProviders(): ISessionsProvider[] { - return Array.from(this._providers.values(), e => e.provider); + return Array.from(this._providers.values()); } getProvider(providerId: string): T | undefined { - return this._providers.get(providerId)?.provider as T | undefined; + return this._providers.get(providerId) as T | undefined; } } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index 419ef69db8990..f3a92c1c6f1d2 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -113,6 +113,11 @@ export interface ISessionsManagementService { */ createNewSession(providerId: string, workspace: ISessionWorkspace): ISession; + /** + * Unset the new session + */ + unsetNewSession(): void; + /** * Send a request, creating a new chat in the session. */ diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index bd771a1ddecd2..ea4d6930de8a4 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -201,8 +201,10 @@ import '../workbench/contrib/policyExport/electron-browser/policyExport.contribu import '../platform/agentHost/electron-browser/agentHostService.js'; import '../platform/agentHost/electron-browser/remoteAgentHostService.js'; import '../platform/agentHost/electron-browser/sshRemoteAgentHostService.js'; +import './contrib/remoteAgentHost/electron-browser/tunnelAgentHostService.js'; import './contrib/remoteAgentHost/browser/remoteAgentHost.contribution.js'; import './contrib/remoteAgentHost/browser/remoteAgentHostActions.js'; +import './contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.js'; // Local Agent Host import './contrib/localAgentHost/browser/localAgentHost.contribution.js'; diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index beef61f2322f1..f718503bce79b 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -30,7 +30,7 @@ import { IChatWidget, IChatWidgetService } from '../../contrib/chat/browser/chat import { AgentSessionProviders, getAgentSessionProvider } from '../../contrib/chat/browser/agentSessions/agentSessions.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/participants/chatAgents.js'; -import { IPromptFileContext, IPromptsService, PromptsStorage } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IAgentSkill, IChatPromptSlashCommand, ICustomAgent, IInstructionFile, IPromptFileContext, IPromptsService, PromptsStorage } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { isValidPromptType, PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js'; @@ -43,7 +43,7 @@ import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/lang import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; +import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; @@ -199,6 +199,12 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA void this._pushSkills(); })); + // Push slash commands to ext host + void this._pushSlashCommands(); + this._register(this._promptsService.onDidChangeSlashCommands(() => { + void this._pushSlashCommands(); + })); + // Push hooks to ext host void this._pushHooks(); this._register(this._promptsService.onDidChangeHooks(() => { @@ -219,11 +225,103 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._proxy.$acceptActiveChatSession(isLocal ? sessionResource : undefined); } + private _toChatResourceSource(storage: PromptsStorage): ICustomAgentDto['source'] { + switch (storage) { + case PromptsStorage.local: + return 'local'; + case PromptsStorage.user: + return 'user'; + case PromptsStorage.extension: + return 'extension'; + case PromptsStorage.plugin: + return 'plugin'; + } + } + + private _toCustomAgentDto(agent: ICustomAgent): ICustomAgentDto { + return { + uri: agent.uri, + name: agent.name, + description: agent.description, + source: this._toChatResourceSource(agent.source.storage), + extensionId: agent.source.storage === PromptsStorage.extension ? agent.source.extensionId.value : undefined, + pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, + argumentHint: agent.argumentHint, + userInvocable: agent.visibility.userInvocable, + disableModelInvocation: !agent.visibility.agentInvocable, + }; + } + + private _toInstructionDto(instruction: IInstructionFile): IInstructionDto { + return { + uri: instruction.uri, + name: instruction.name, + description: instruction.description, + source: this._toChatResourceSource(instruction.storage), + extensionId: instruction.extension?.identifier.value, + pluginUri: instruction.pluginUri, + pattern: instruction.pattern, + }; + } + + private _toSkillDto(skill: IAgentSkill): ISkillDto { + return { + uri: skill.uri, + name: skill.name, + description: skill.description, + source: this._toChatResourceSource(skill.storage), + extensionId: skill.extension?.identifier.value, + pluginUri: skill.pluginUri, + userInvocable: skill.userInvocable, + }; + } + + private _toSlashCommandDto(slashCommand: IChatPromptSlashCommand): ISlashCommandDto { + return { + uri: slashCommand.uri, + name: slashCommand.name, + description: slashCommand.description, + source: this._toChatResourceSource(slashCommand.storage), + extensionId: slashCommand.extension?.identifier.value, + pluginUri: slashCommand.pluginUri, + argumentHint: slashCommand.argumentHint, + userInvocable: slashCommand.userInvocable, + }; + } + + async $provideCustomAgents(token: CancellationToken): Promise { + const customAgents = await this._promptsService.getCustomAgents(token); + return customAgents.map(agent => this._toCustomAgentDto(agent)); + } + + async $provideInstructions(token: CancellationToken): Promise { + const instructions = await this._promptsService.getInstructionFiles(token); + return instructions.map(instruction => this._toInstructionDto(instruction)); + } + + async $provideSkills(token: CancellationToken): Promise { + const skills = await this._promptsService.findAgentSkills(token) ?? []; + return skills.map(skill => this._toSkillDto(skill)); + } + + async $provideSlashCommands(token: CancellationToken): Promise { + const slashCommands = await this._promptsService.getPromptSlashCommands(token); + return slashCommands.map(slashCommand => this._toSlashCommandDto(slashCommand)); + } + + async $provideHooks(token: CancellationToken): Promise { + const hookFiles = await this._promptsService.listPromptFiles(PromptsType.hook, token); + return hookFiles.map(hookFile => ({ uri: hookFile.uri })); + } + + async $providePlugins(_token: CancellationToken): Promise { + const plugins = this._agentPluginService.plugins.get(); + return plugins.map(plugin => ({ uri: plugin.uri })); + } + private async _pushCustomAgents(): Promise { try { - const customAgents = await this._promptsService.getCustomAgents(CancellationToken.None); - const dtos: ICustomAgentDto[] = customAgents.map(agent => ({ uri: agent.uri })); - this._proxy.$acceptCustomAgents(dtos); + this._proxy.$acceptCustomAgents(await this.$provideCustomAgents(CancellationToken.None)); } catch (error) { this._logService.error('[chat] Failed to push custom agents to extension host', error); } @@ -231,9 +329,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA private async _pushInstructions(): Promise { try { - const instructions = await this._promptsService.getInstructionFiles(CancellationToken.None); - const dtos: IInstructionDto[] = instructions.map(instruction => ({ uri: instruction.uri })); - this._proxy.$acceptInstructions(dtos); + this._proxy.$acceptInstructions(await this.$provideInstructions(CancellationToken.None)); } catch (error) { this._logService.error('[chat] Failed to push instructions to extension host', error); } @@ -241,19 +337,23 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA private async _pushSkills(): Promise { try { - const skills = await this._promptsService.findAgentSkills(CancellationToken.None) ?? []; - const dtos: ISkillDto[] = skills.map(skill => ({ uri: skill.uri })); - this._proxy.$acceptSkills(dtos); + this._proxy.$acceptSkills(await this.$provideSkills(CancellationToken.None)); } catch (error) { this._logService.error('[chat] Failed to push skills to extension host', error); } } + private async _pushSlashCommands(): Promise { + try { + this._proxy.$acceptSlashCommands(await this.$provideSlashCommands(CancellationToken.None)); + } catch (error) { + this._logService.error('[chat] Failed to push slash commands to extension host', error); + } + } + private async _pushHooks(): Promise { try { - const hookFiles = await this._promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); - const dtos: IHookDto[] = hookFiles.map(hookFile => ({ uri: hookFile.uri })); - this._proxy.$acceptHooks(dtos); + this._proxy.$acceptHooks(await this.$provideHooks(CancellationToken.None)); } catch (error) { this._logService.error('[chat] Failed to push hooks to extension host', error); } diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index f36df0c7f7b03..7521c370ee990 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -10,7 +10,7 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { autorun, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import Severity from '../../../base/common/severity.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as nls from '../../../nls.js'; import { ContextKeyExpr, IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; @@ -401,8 +401,11 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { this._telemetryService.publicLog2('mcp/authSetup', data); } - async $startMcpGateway(): Promise<{ servers: { label: string; address: URI }[]; gatewayId: string } | undefined> { - const result = await this._mcpGatewayService.createGateway(this._extHostContext.extensionHostKind === ExtensionHostKind.Remote); + async $startMcpGateway(chatSessionResource?: UriComponents): Promise<{ servers: { label: string; address: URI }[]; gatewayId: string } | undefined> { + const result = await this._mcpGatewayService.createGateway( + this._extHostContext.extensionHostKind === ExtensionHostKind.Remote, + chatSessionResource ? URI.revive(chatSessionResource) : undefined, + ); if (!result) { return undefined; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 512a1074be285..7efd8ca6185d6 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1719,7 +1719,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, get customAgents() { checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.customAgents as readonly vscode.ChatResource[]; + return extHostChatAgents2.customAgents as readonly vscode.ChatCustomAgent[]; + }, + getCustomAgents(token: vscode.CancellationToken) { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.provideCustomAgents(token) as Thenable; }, onDidChangeCustomAgents: (listener, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatPromptFiles'); @@ -1727,7 +1731,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, get instructions() { checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.instructions as readonly vscode.ChatResource[]; + return extHostChatAgents2.instructions as readonly vscode.ChatInstruction[]; + }, + getInstructions(token: vscode.CancellationToken) { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.provideInstructions(token) as Thenable; }, onDidChangeInstructions: (listener, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatPromptFiles'); @@ -1735,16 +1743,36 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I }, get skills() { checkProposedApiEnabled(extension, 'chatPromptFiles'); - return extHostChatAgents2.skills as readonly vscode.ChatResource[]; + return extHostChatAgents2.skills as readonly vscode.ChatSkill[]; + }, + getSkills(token: vscode.CancellationToken) { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.provideSkills(token) as Thenable; }, onDidChangeSkills: (listener, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeSkills(listener, thisArgs, disposables); }, + get slashCommands() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.slashCommands as readonly vscode.ChatSlashCommand[]; + }, + getSlashCommands(token: vscode.CancellationToken) { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.provideSlashCommands(token) as Thenable; + }, + onDidChangeSlashCommands: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeSlashCommands(listener, thisArgs, disposables); + }, get hooks() { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.hooks as readonly vscode.ChatResource[]; }, + getHooks(token: vscode.CancellationToken) { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.provideHooks(token) as Thenable; + }, onDidChangeHooks: (listener, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangeHooks(listener, thisArgs, disposables); @@ -1753,6 +1781,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.plugins as readonly vscode.ChatResource[]; }, + getPlugins(token: vscode.CancellationToken) { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.providePlugins(token) as Thenable; + }, onDidChangePlugins: (listener, thisArgs?, disposables?) => { checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.onDidChangePlugins(listener, thisArgs, disposables); @@ -1843,9 +1875,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'mcpServerDefinitions'); return extHostMcp.mcpServerDefinitions; }, - startMcpGateway() { + startMcpGateway(chatSessionResource?: URI) { checkProposedApiEnabled(extension, 'mcpServerDefinitions'); - return extHostMcp.startMcpGateway(); + return extHostMcp.startMcpGateway(chatSessionResource); }, onDidChangeChatRequestTools(...args) { checkProposedApiEnabled(extension, 'chatParticipantAdditions'); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 3c9d7f0bac8e3..f5deb7a791c3a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1596,6 +1596,12 @@ export interface MainThreadChatAgentsShape2 extends IChatAgentProgressShape, IDi $unregisterAgent(handle: number): void; $transferActiveChatSession(toWorkspace: UriComponents): Promise; + $provideCustomAgents(token: CancellationToken): Promise; + $provideInstructions(token: CancellationToken): Promise; + $provideSkills(token: CancellationToken): Promise; + $provideSlashCommands(token: CancellationToken): Promise; + $provideHooks(token: CancellationToken): Promise; + $providePlugins(token: CancellationToken): Promise; } export interface ICodeMapperTextEdit { @@ -1671,20 +1677,39 @@ export interface ExtHostChatAgentsShape2 { $acceptCustomAgents(agents: ICustomAgentDto[]): void; $acceptInstructions(instructions: IInstructionDto[]): void; $acceptSkills(skills: ISkillDto[]): void; + $acceptSlashCommands(slashCommands: ISlashCommandDto[]): void; $acceptHooks(hooks: IHookDto[]): void; $acceptPlugins(plugins: IPluginDto[]): void; } -export interface ICustomAgentDto { - uri: UriComponents; +export type IChatResourceSourceDto = 'local' | 'user' | 'extension' | 'plugin'; + +export interface IChatResourceDto { + readonly uri: UriComponents; + readonly name: string; + readonly description?: string; + readonly source: IChatResourceSourceDto; + readonly extensionId?: string; + readonly pluginUri?: UriComponents; } -export interface IInstructionDto { - uri: UriComponents; +export interface ICustomAgentDto extends IChatResourceDto { + readonly argumentHint?: string; + readonly userInvocable: boolean; + readonly disableModelInvocation: boolean; } -export interface ISkillDto { - uri: UriComponents; +export interface IInstructionDto extends IChatResourceDto { + readonly pattern?: string; +} + +export interface ISkillDto extends IChatResourceDto { + readonly userInvocable: boolean; +} + +export interface ISlashCommandDto extends IChatResourceDto { + readonly argumentHint?: string; + readonly userInvocable: boolean; } export interface IHookDto { @@ -3516,7 +3541,7 @@ export interface MainThreadMcpShape { $getTokenFromServerMetadata(id: number, authDetails: IMcpAuthenticationDetails, options?: IMcpAuthenticationOptions): Promise; $getTokenForProviderId(id: number, providerId: string, scopes: string[], options?: IMcpAuthenticationOptions): Promise; $logMcpAuthSetup(data: IAuthMetadataSource): void; - $startMcpGateway(): Promise<{ servers: { label: string; address: UriComponents }[]; gatewayId: string } | undefined>; + $startMcpGateway(chatSessionResource?: UriComponents): Promise<{ servers: { label: string; address: UriComponents }[]; gatewayId: string } | undefined>; $disposeMcpGateway(gatewayId: string): void; } diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 2c60850754dd2..f6bdc8bce28d7 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -28,7 +28,7 @@ import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js' import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentInvokeResult, IChatAgentProgressShape, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IMainContext, IPluginDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; +import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentInvokeResult, IChatAgentProgressShape, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IMainContext, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostDiagnostics } from './extHostDiagnostics.js'; import { ExtHostDocuments } from './extHostDocuments.js'; @@ -498,16 +498,19 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS readonly onDidChangeInstructions = this._onDidChangeInstructions.event; private readonly _onDidChangeSkills = this._register(new Emitter()); readonly onDidChangeSkills = this._onDidChangeSkills.event; + private readonly _onDidChangeSlashCommands = this._register(new Emitter()); + readonly onDidChangeSlashCommands = this._onDidChangeSlashCommands.event; private readonly _onDidChangeHooks = this._register(new Emitter()); readonly onDidChangeHooks = this._onDidChangeHooks.event; private readonly _onDidChangePlugins = this._register(new Emitter()); readonly onDidChangePlugins = this._onDidChangePlugins.event; - private _customAgents: vscode.ChatResource[] = []; - private _instructions: vscode.ChatResource[] = []; - private _skills: vscode.ChatResource[] = []; - private _hooks: vscode.ChatResource[] = []; - private _plugins: vscode.ChatResource[] = []; + private _customAgents: vscode.ChatCustomAgent[] = []; + private _instructions: vscode.ChatInstruction[] = []; + private _skills: vscode.ChatSkill[] = []; + private _slashCommands: vscode.ChatSlashCommand[] = []; + private _hooks: vscode.ChatHook[] = []; + private _plugins: vscode.ChatPlugin[] = []; private _activeChatPanelSessionResource: URI | undefined; @@ -518,48 +521,140 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return this._activeChatPanelSessionResource; } - get customAgents(): readonly vscode.ChatResource[] { + get customAgents(): readonly vscode.ChatCustomAgent[] { return this._customAgents; } - get instructions(): readonly vscode.ChatResource[] { + get instructions(): readonly vscode.ChatInstruction[] { return this._instructions; } - get skills(): readonly vscode.ChatResource[] { + get skills(): readonly vscode.ChatSkill[] { return this._skills; } - get hooks(): readonly vscode.ChatResource[] { + get slashCommands(): readonly vscode.ChatSlashCommand[] { + return this._slashCommands; + } + + private toCustomAgent(dto: ICustomAgentDto): vscode.ChatCustomAgent { + return Object.freeze({ + uri: URI.revive(dto.uri), + name: dto.name, + description: dto.description, + source: dto.source, + extensionId: dto.extensionId, + pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + argumentHint: dto.argumentHint, + userInvocable: dto.userInvocable, + disableModelInvocation: dto.disableModelInvocation, + }); + } + + private toInstruction(dto: IInstructionDto): vscode.ChatInstruction { + return Object.freeze({ + uri: URI.revive(dto.uri), + name: dto.name, + description: dto.description, + source: dto.source, + extensionId: dto.extensionId, + pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + pattern: dto.pattern, + }); + } + + private toSkill(dto: ISkillDto): vscode.ChatSkill { + return Object.freeze({ + uri: URI.revive(dto.uri), + name: dto.name, + description: dto.description, + source: dto.source, + extensionId: dto.extensionId, + pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + userInvocable: dto.userInvocable, + }); + } + + private toSlashCommand(dto: ISlashCommandDto): vscode.ChatSlashCommand { + return Object.freeze({ + uri: URI.revive(dto.uri), + name: dto.name, + description: dto.description, + source: dto.source, + extensionId: dto.extensionId, + pluginUri: dto.pluginUri ? URI.revive(dto.pluginUri) : undefined, + argumentHint: dto.argumentHint, + userInvocable: dto.userInvocable, + }); + } + + private toHook(dto: IHookDto): vscode.ChatHook { + return Object.freeze({ uri: URI.revive(dto.uri) }); + } + + private toPlugin(dto: IPluginDto): vscode.ChatPlugin { + return Object.freeze({ uri: URI.revive(dto.uri) }); + } + + get hooks(): readonly vscode.ChatHook[] { return this._hooks; } - get plugins(): readonly vscode.ChatResource[] { + get plugins(): readonly vscode.ChatPlugin[] { return this._plugins; } + provideCustomAgents(token: vscode.CancellationToken): Thenable { + return this._proxy.$provideCustomAgents(token).then(agents => this._customAgents = agents.map(agent => this.toCustomAgent(agent))); + } + + provideInstructions(token: vscode.CancellationToken): Thenable { + return this._proxy.$provideInstructions(token).then(instructions => this._instructions = instructions.map(instruction => this.toInstruction(instruction))); + } + + provideSkills(token: vscode.CancellationToken): Thenable { + return this._proxy.$provideSkills(token).then(skills => this._skills = skills.map(skill => this.toSkill(skill))); + } + + provideSlashCommands(token: vscode.CancellationToken): Thenable { + return this._proxy.$provideSlashCommands(token).then(slashCommands => this._slashCommands = slashCommands.map(slashCommand => this.toSlashCommand(slashCommand))); + } + + provideHooks(token: vscode.CancellationToken): Thenable { + return this._proxy.$provideHooks(token).then(hooks => this._hooks = hooks.map(hook => this.toHook(hook))); + } + + providePlugins(token: vscode.CancellationToken): Thenable { + return this._proxy.$providePlugins(token).then(plugins => this._plugins = plugins.map(plugin => this.toPlugin(plugin))); + } + $acceptCustomAgents(agents: ICustomAgentDto[]): void { - this._customAgents = agents.map(a => Object.freeze({ uri: URI.revive(a.uri) })); + this._customAgents = agents.map(agent => this.toCustomAgent(agent)); this._onDidChangeCustomAgents.fire(); } $acceptInstructions(instructions: IInstructionDto[]): void { - this._instructions = instructions.map(i => Object.freeze({ uri: URI.revive(i.uri) })); + this._instructions = instructions.map(instruction => this.toInstruction(instruction)); this._onDidChangeInstructions.fire(); } $acceptSkills(skills: ISkillDto[]): void { - this._skills = skills.map(s => Object.freeze({ uri: URI.revive(s.uri) })); + this._skills = skills.map(skill => this.toSkill(skill)); this._onDidChangeSkills.fire(); } + $acceptSlashCommands(slashCommands: ISlashCommandDto[]): void { + this._slashCommands = slashCommands.map(slashCommand => this.toSlashCommand(slashCommand)); + this._onDidChangeSlashCommands.fire(); + } + $acceptHooks(hooks: IHookDto[]): void { - this._hooks = hooks.map(h => Object.freeze({ uri: URI.revive(h.uri) })); + this._hooks = hooks.map(hook => this.toHook(hook)); this._onDidChangeHooks.fire(); } $acceptPlugins(plugins: IPluginDto[]): void { - this._plugins = plugins.map(p => Object.freeze({ uri: URI.revive(p.uri) })); + this._plugins = plugins.map(plugin => this.toPlugin(plugin)); this._onDidChangePlugins.fire(); } diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 1554db1996bec..f7436ea459015 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -42,7 +42,7 @@ export interface IExtHostMpcService extends ExtHostMcpShape { readonly mcpServerDefinitions: readonly vscode.McpServerDefinition[]; /** Starts an MCP gateway that exposes MCP servers via HTTP endpoints. */ - startMcpGateway(): Promise; + startMcpGateway(chatSessionResource?: URI): Promise; } const serverDataValidation = vObj({ @@ -264,8 +264,8 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService } /** {@link vscode.lm.startMcpGateway} */ - public async startMcpGateway(): Promise { - const result = await this._proxy.$startMcpGateway(); + public async startMcpGateway(chatSessionResource?: URI): Promise { + const result = await this._proxy.$startMcpGateway(chatSessionResource?.toJSON()); if (!result) { return undefined; } diff --git a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts index cd4490f2b6c85..6521536017e36 100644 --- a/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/commandCenterControl.ts @@ -26,7 +26,6 @@ import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; const AI_DISABLED_SETTING = 'chat.disableAIFeatures'; -const AI_CUSTOMIZATION_MENU_ENABLED_SETTING = 'chat.customizationsMenu.enabled'; const AGENT_STATUS_ENABLED_SETTING = 'chat.agentsControl.enabled'; export class CommandCenterControl { @@ -168,8 +167,7 @@ class CommandCenterCenterViewItem extends BaseActionViewItem { // Backward compat: the old boolean setting (true) and the new default (undefined) both map to compact const aiFeaturesDisabled = that._configurationService.getValue(AI_DISABLED_SETTING) === true; const aiCustomizationsDisabled = that._configurationService.getValue('disableAICustomizations') === true - || that._configurationService.getValue('workbench.disableAICustomizations') === true - || that._configurationService.getValue(AI_CUSTOMIZATION_MENU_ENABLED_SETTING) === false; + || that._configurationService.getValue('workbench.disableAICustomizations') === true; const forcedHidden = aiFeaturesDisabled && aiCustomizationsDisabled; const agentControlValue = that._configurationService.getValue(AGENT_STATUS_ENABLED_SETTING); const isCompactMode = !forcedHidden && (agentControlValue === true || agentControlValue === undefined || agentControlValue === 'compact'); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 08eba39df4c29..dce68aa6162ac 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -18,7 +18,11 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { Event } from '../../../../../base/common/event.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { URI } from '../../../../../base/common/uri.js'; import { IChatWidgetService } from '../../../chat/browser/chat.js'; import { IChatRequestVariableEntry } from '../../../chat/common/attachments/chatVariableEntries.js'; import { ChatContextKeys } from '../../../chat/common/actions/chatContextKeys.js'; @@ -74,6 +78,9 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { @ILogService private readonly logService: ILogService, @IChatWidgetService private readonly chatWidgetService: IChatWidgetService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + @IStorageService private readonly storageService: IStorageService, + @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(editor); this._elementSelectionActiveContext = CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE.bindTo(contextKeyService); @@ -161,6 +168,50 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { : localize('browser.shareWithAgent', "Share with Agent")); } + private static readonly SHARING_CONTENT_WARNING_DONT_ASK_KEY = 'browserView.agentSharingContentWarning.dontAskAgain'; + + /** + * Confirm with the user that they understand the risks of sharing content on untrusted pages. + * + * @returns true if the user confirms (or the page is local / trusted), false if they cancel. + */ + private async _confirmContentAttachmentRisk(url: string): Promise { + // If the user previously chose "Don't show again", skip the dialog + if (this.storageService.getBoolean(BrowserEditorChatIntegration.SHARING_CONTENT_WARNING_DONT_ASK_KEY, StorageScope.PROFILE)) { + return true; + } + + try { + const parsedUrl = new URL(url); + if (parsedUrl.protocol === 'file:') { + // Query the workspace trust service for file URLs + const trustInfo = await this.workspaceTrustManagementService.getUriTrustInfo(URI.file(parsedUrl.pathname)); + if (trustInfo.trusted) { + return true; + } + } else if (parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1' || parsedUrl.hostname === '::1') { + // Consider localhost URLs trusted + return true; + } + } catch { + // Invalid URL - fall through to the warning + } + + const result = await this.dialogService.confirm({ + type: 'warning', + message: localize('browser.agentSharingContentWarning.message', "Use caution when attaching content from untrusted sources."), + detail: localize('browser.agentSharingContentWarning.detail', "Pages may contain hidden prompts that can influence agent behavior. Double-check the attached contents before sending."), + primaryButton: localize('browser.agentSharingContentWarning.ok', "&&OK"), + checkbox: { label: localize('browser.agentSharingContentWarning.dontShowAgain', "Don't show again"), checked: false }, + }); + + if (result.confirmed && result.checkboxChecked) { + this.storageService.store(BrowserEditorChatIntegration.SHARING_CONTENT_WARNING_DONT_ASK_KEY, true, StorageScope.PROFILE, StorageTarget.USER); + } + + return result.confirmed; + } + // -- Element Selection ---------------------------------------------- /** @@ -170,8 +221,6 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { // If selection is already active, cancel it if (this._elementSelectionCts) { this._elementSelectionCts.dispose(true); - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); return; } @@ -179,6 +228,12 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { const cts = new CancellationTokenSource(); this._elementSelectionCts = cts; this._elementSelectionActiveContext.set(true); + cts.token.onCancellationRequested(() => { + if (this._elementSelectionCts === cts) { + this._elementSelectionCts = undefined; + this._elementSelectionActiveContext.set(false); + } + }); type IntegratedBrowserAddElementToChatStartEvent = {}; @@ -204,24 +259,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { throw new Error('Element data not found'); } - const { attachCss, attachImages } = await this._attachElementDataToChat(elementData); - - type IntegratedBrowserAddElementToChatAddedEvent = { - attachCss: boolean; - attachImages: boolean; - }; - - type IntegratedBrowserAddElementToChatAddedClassification = { - attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' }; - attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; - owner: 'jruales'; - comment: 'An element was successfully added to chat from Integrated Browser.'; - }; - - this.telemetryService.publicLog2('integratedBrowser.addElementToChat.added', { - attachCss, - attachImages - }); + await this._attachElementDataToChat(elementData, model); } catch (error) { if (!cts.token.isCancellationRequested) { @@ -229,10 +267,6 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { } } finally { cts.dispose(true); - if (this._elementSelectionCts === cts) { - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); - } } } @@ -250,20 +284,19 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { return; } - const elementData = await model.getFocusedElementData(); - if (!elementData) { - return; - } + try { + const elementData = await model.getFocusedElementData(); + if (!elementData) { + return; + } - await this._attachElementDataToChat(elementData); - cts.dispose(true); - if (this._elementSelectionCts === cts) { - this._elementSelectionCts = undefined; - this._elementSelectionActiveContext.set(false); + await this._attachElementDataToChat(elementData, model); + } finally { + cts.dispose(true); } } - private async _attachElementDataToChat(elementData: IElementData): Promise<{ attachCss: boolean; attachImages: boolean }> { + private async _attachElementDataToChat(elementData: IElementData, model: IBrowserViewModel) { const bounds = elementData.bounds; const toAttach: IChatRequestVariableEntry[] = []; @@ -306,8 +339,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { }); const attachImages = this.configurationService.getValue('chat.sendElementsToChat.attachImages'); - const model = this.editor.model; - if (attachImages && model) { + if (attachImages) { const screenshotBuffer = await model.captureScreenshot({ quality: 90, pageRect: bounds @@ -322,10 +354,29 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { }); } + if (!await this._confirmContentAttachmentRisk(elementData.url ?? model.url)) { + return; + } + const widget = await this.chatWidgetService.revealWidget() ?? this.chatWidgetService.lastFocusedWidget; widget?.attachmentModel?.addContext(...toAttach); - return { attachCss, attachImages }; + type IntegratedBrowserAddElementToChatAddedEvent = { + attachCss: boolean; + attachImages: boolean; + }; + + type IntegratedBrowserAddElementToChatAddedClassification = { + attachCss: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachCSS was enabled.' }; + attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; + owner: 'jruales'; + comment: 'An element was successfully added to chat from Integrated Browser.'; + }; + + this.telemetryService.publicLog2('integratedBrowser.addElementToChat.added', { + attachCss, + attachImages + }); } // -- Console Logs --------------------------------------------------- @@ -345,6 +396,10 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { return; } + if (!await this._confirmContentAttachmentRisk(model.url)) { + return; + } + const toAttach: IChatRequestVariableEntry[] = []; toAttach.push({ id: 'console-logs-' + Date.now(), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index d5eeb3e8af342..680ce9c00ecee 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -17,6 +17,33 @@ import type { Page } from 'playwright-core'; export const DEFAULT_ELEMENT_LABEL = localize('browser.element', 'element'); +export interface FormatBrowserEditorLinesOptions { + indent?: string; + numbered?: boolean; + excludeIds?: boolean; +} + +/** + * Formats a list of browser editors as summary lines such as + * `- [pageId] Title (url) (active)`. Active/visible hints are + * derived from the editor service automatically. + */ +export function formatBrowserEditorList(editorService: IEditorService, editors: readonly BrowserEditorInput[], options?: FormatBrowserEditorLinesOptions): string { + const activeEditor = editorService.activeEditor; + const visibleEditors = new Set(editorService.visibleEditors); + const indent = options?.indent ?? ''; + return editors.map((editor, index) => { + const title = editor.title || 'Untitled'; + const url = editor.url || 'about:blank'; + const hint = editor === activeEditor ? ' (active)' : visibleEditors.has(editor) ? ' (visible)' : ''; + const id = options?.excludeIds ? '' : `[${editor.id}] `; + + // By default, use numbers only if we're excluding IDs, so models don't get confused about which ID to use. + const bullet = (options?.numbered ?? options?.excludeIds) ? `${index + 1}. ` : '- '; + return `${indent}${bullet}${id}${title} (${url})${hint}`; + }).join('\n'); +} + /** * Creates a markdown link to a browser page. */ @@ -105,20 +132,21 @@ export function errorResult(message: string): IToolResult { * * @returns The first matching {@link BrowserEditorInput}, or `undefined` if none was found. */ -export async function findExistingPageByHost( +async function findExistingPagesByHost( editorService: IEditorService, playwrightService: IPlaywrightService | undefined, url: string, -): Promise { +): Promise { const parsed = URL.parse(url); - if (!parsed?.host) { - return undefined; + if (!parsed || (parsed.protocol !== 'file:' && !parsed.host)) { + return []; } const trackedIds = playwrightService ? new Set(await playwrightService.getTrackedPages()) : undefined; + const results: BrowserEditorInput[] = []; for (const editor of editorService.editors) { if (!(editor instanceof BrowserEditorInput)) { continue; @@ -126,25 +154,40 @@ export async function findExistingPageByHost( if (trackedIds && !trackedIds.has(editor.id)) { continue; } - const editorUrl = editor.url; - if (editorUrl && URL.parse(editorUrl)?.host === parsed.host) { - return editor; + const editorUrl = URL.parse(editor.url || ''); + if ( + !editor.url || + editorUrl?.host === parsed.host || + (parsed.protocol === 'file:' && editorUrl?.protocol === 'file:') + ) { + results.push(editor); } } - return undefined; + return results; } /** * Builds the "already open" tool result returned when an existing page with the - * same host is found by {@link findExistingPageByHost}. + * same host is found by {@link findExistingPagesByHost}. */ -export function alreadyOpenResult(existing: BrowserEditorInput): IToolResult { - const link = createBrowserPageLink(existing.id); +export async function getExistingPagesResult( + editorService: IEditorService, + playwrightService: IPlaywrightService | undefined, + url: string, + formatOptions?: FormatBrowserEditorLinesOptions +): Promise { + const existing = await findExistingPagesByHost(editorService, playwrightService, url); + if (existing.length === 0) { + return undefined; + } + + const list = formatBrowserEditorList(editorService, existing, { indent: ' ', ...formatOptions }); + const links = existing.map(e => createBrowserPageLink(e.id)); return { content: [{ kind: 'text', - value: `A page on this host is already open (Page ID: ${existing.id}). Use this page or pass \`forceNew: true\` to open a new one.`, + value: `At least one similar page is already open:\n${list}\n\nUse an existing page or pass \`forceNew: true\` to open a new one.` }], - toolResultMessage: new MarkdownString(localize('browser.open.alreadyOpen', "Already open: {0}", link)), + toolResultMessage: new MarkdownString(localize('browser.open.alreadyOpen', "Already open: {0}", links.join(', '))), }; } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts index b113304a284b5..a01f121e4a9e8 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserTools.contribution.ts @@ -14,6 +14,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { IChatContextService } from '../../../chat/browser/contextContrib/chatContextService.js'; import { ILanguageModelToolsService, ToolDataSource, ToolSet } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; +import { formatBrowserEditorList } from './browserToolHelpers.js'; import { ClickBrowserTool, ClickBrowserToolData } from './clickBrowserTool.js'; import { DragElementTool, DragElementToolData } from './dragElementTool.js'; import { HandleDialogBrowserTool, HandleDialogBrowserToolData } from './handleDialogBrowserTool.js'; @@ -112,28 +113,24 @@ class BrowserChatAgentToolsContribution extends Disposable implements IWorkbench } private _updateBrowserContext(): void { - const lines: string[] = []; - const activeEditor = this.editorService.activeEditor; - const visibleEditors = new Set(this.editorService.visibleEditors); + const trackedEditors: BrowserEditorInput[] = []; for (const editor of this.editorService.editors) { if (editor instanceof BrowserEditorInput && this._trackedIds.has(editor.id)) { - const title = editor.getTitle() || 'Untitled'; - const url = editor.getDescription() || 'about:blank'; - const hint = editor === activeEditor ? ' (active)' : visibleEditors.has(editor) ? ' (visible)' : ''; - lines.push(`- [${editor.id}] ${title} (${url})${hint}`); + trackedEditors.push(editor); } } - if (lines.length === 0) { + if (trackedEditors.length === 0) { this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, []); return; } + const list = formatBrowserEditorList(this.editorService, trackedEditors); this.chatContextService.updateWorkspaceContextItems(BrowserChatAgentToolsContribution.CONTEXT_ID, [{ handle: 0, label: localize('browserContext.label', "Browser Pages"), - modelDescription: `The following browser pages are currently available and can be interacted with using the browser tools:`, - value: lines.join('\n'), + modelDescription: `The following browser pages are currently available and can be interacted with using the browser tools`, + value: list }]); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 564f70baa324f..c9a4a88f8824a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { alreadyOpenResult, createBrowserPageLink, findExistingPageByHost } from './browserToolHelpers.js'; +import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; export const OpenPageToolId = 'open_browser_page'; @@ -75,9 +75,9 @@ export class OpenBrowserTool implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existing = await findExistingPageByHost(this.editorService, this.playwrightService, params.url); - if (existing) { - return alreadyOpenResult(existing); + const existingResult = await getExistingPagesResult(this.editorService, this.playwrightService, params.url); + if (existingResult) { + return existingResult; } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts index 7c07497149307..12ca0b468f71e 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -13,7 +13,7 @@ import { IEditorService } from '../../../../services/editor/common/editorService import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { alreadyOpenResult, createBrowserPageLink, findExistingPageByHost } from './browserToolHelpers.js'; +import { createBrowserPageLink, getExistingPagesResult } from './browserToolHelpers.js'; export const OpenBrowserToolNonAgenticData: IToolData = { ...OpenBrowserToolData, @@ -52,9 +52,9 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { const params = invocation.parameters as IOpenBrowserToolParams; if (!params.forceNew) { - const existing = await findExistingPageByHost(this.editorService, undefined, params.url); - if (existing) { - return alreadyOpenResult(existing); + const existingResult = await getExistingPagesResult(this.editorService, undefined, params.url, { excludeIds: true }); + if (existingResult) { + return existingResult; } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 2c89110cf8c7c..cd85b8ebc5841 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1440,7 +1440,7 @@ export function registerChatActions() { }, { id: MenuId.ViewTitle, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), order: 15, group: '3_configure' }] @@ -1453,7 +1453,7 @@ export function registerChatActions() { } }); - // When customizations menu is enabled, show a direct gear action to open the Customizations editor + // Show a direct gear action to open the Customizations editor MenuRegistry.appendMenuItem(MenuId.ViewTitle, { command: { id: AICustomizationManagementCommands.OpenEditor, @@ -1465,20 +1465,9 @@ export function registerChatActions() { when: ContextKeyExpr.and( ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), - ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`) ), order: 6 }); - - // When customizations menu is disabled, show the legacy gear submenu - MenuRegistry.appendMenuItem(MenuId.ViewTitle, { - submenu: CHAT_CONFIG_MENU_ID, - title: localize2('config.label', "Configure Chat"), - group: 'navigation', - when: ContextKeyExpr.and(ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`).negate()), - icon: Codicon.gear, - order: 6 - }); } export function stringifyItem(item: IChatRequestViewModel | IChatResponseViewModel, includeName = true): string { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts index 156b847d16f00..673ca6f1d7177 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatOpenAgentDebugPanelAction.ts @@ -23,7 +23,6 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatDebugService } from '../../common/chatDebugService.js'; import { ChatViewId, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from './chatActions.js'; -import { ChatConfiguration } from '../../common/constants.js'; import { ChatDebugEditorInput } from '../chatDebug/chatDebugEditorInput.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IChatDebugEditorOptions } from '../chatDebug/chatDebugTypes.js'; @@ -76,7 +75,7 @@ export function registerChatOpenAgentDebugPanelAction() { when: ChatContextKeys.inChatEditor.negate() }, { id: MenuId.ViewTitle, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), order: 0, group: '4_logs' }] diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 8a8c075705cdb..82674d1cb26f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -10,8 +10,7 @@ import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AgentHostEnabledSettingId, IAgentHostService, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; -import { type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { isSessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { type IProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import { ROOT_STATE_URI, type IAgentInfo, type ICustomizationRef, type IRootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -90,11 +89,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr // Forward action envelopes from the host to client state this._register(this._loggedConnection.onDidAction(envelope => { - // Only root actions are relevant here; session actions are - // handled by individual session handlers. - if (!isSessionAction(envelope.action)) { - this._clientState!.receiveEnvelope(envelope); - } + this._clientState!.receiveEnvelope(envelope); })); // Forward notifications to client state @@ -135,6 +130,10 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } } + // Authenticate using protectedResources from agent info + this._authenticateWithServer(rootState.agents) + .catch(() => { /* best-effort */ }); + // Register new agents and push model updates to existing ones for (const agent of rootState.agents) { if (!this._agentRegistrations.has(agent.provider)) { @@ -201,7 +200,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr description: agent.description, connection: this._loggedConnection!, connectionAuthority: 'local', - resolveAuthentication: () => this._resolveAuthenticationInteractively(), + clientState: this._clientState!, + resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(resources), customizations, })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -216,12 +216,15 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(toDisposable(() => this._modelProviders.delete(agent.provider))); store.add(this._languageModelsService.registerLanguageModelProvider(vendor, modelProvider)); - // Push auth token and refresh models from server - this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ }); - store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => - this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ }))); - store.add(this._authenticationService.onDidChangeSessions(() => - this._authenticateWithServer().then(() => this._loggedConnection!.refreshModels()).catch(() => { /* best-effort */ }))); + // Re-authenticate when credentials change + store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => { + const agents = this._clientState?.rootState?.agents ?? []; + this._authenticateWithServer(agents).catch(() => { /* best-effort */ }); + })); + store.add(this._authenticationService.onDidChangeSessions(() => { + const agents = this._clientState?.rootState?.agents ?? []; + this._authenticateWithServer(agents).catch(() => { /* best-effort */ }); + })); } /** @@ -267,22 +270,21 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr } /** - * Discover auth requirements from the server's resource metadata - * and authenticate using matching tokens resolved via the standard - * VS Code authentication service (same flow as MCP auth). + * Authenticate using protectedResources from agent info in root state. + * Resolves tokens via the standard VS Code authentication service. */ - private async _authenticateWithServer(): Promise { + private async _authenticateWithServer(agents: readonly IAgentInfo[]): Promise { try { - const metadata = await this._loggedConnection!.getResourceMetadata(); - this._logService.trace(`[AgentHost] Resource metadata: ${metadata.resources.length} resource(s)`); - for (const resource of metadata.resources) { - const resourceUri = URI.parse(resource.resource); - const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); - if (token) { - this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); - await this._loggedConnection!.authenticate({ resource: resource.resource, token }); - } else { - this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); + for (const agent of agents) { + for (const resource of agent.protectedResources ?? []) { + const resourceUri = URI.parse(resource.resource); + const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); + if (token) { + this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); + await this._loggedConnection!.authenticate({ resource: resource.resource, token }); + } else { + this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); + } } } } catch (err) { @@ -301,14 +303,13 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr /** * Interactively prompt the user to authenticate when the server requires it. - * Fetches resource metadata, resolves the auth provider, creates a session - * (which triggers the login UI), and pushes the token to the server. - * Returns true if authentication succeeded. + * Uses protectedResources from root state, resolves the auth provider, + * creates a session (which triggers the login UI), and pushes the token + * to the server. Returns true if authentication succeeded. */ - private async _resolveAuthenticationInteractively(): Promise { + private async _resolveAuthenticationInteractively(protectedResources: IProtectedResourceMetadata[]): Promise { try { - const metadata = await this._loggedConnection!.getResourceMetadata(); - for (const resource of metadata.resources) { + for (const resource of protectedResources) { for (const server of resource.authorization_servers ?? []) { const serverUri = URI.parse(server); const resourceUri = URI.parse(resource.resource); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 9e2f12e055f1f..593046433dca5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -16,8 +16,8 @@ import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { AgentProvider, AgentSession, IAgentAttachment, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { ISessionTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { ICustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ActionType, isSessionAction, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { ICustomizationRef, type IProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ActionType, type ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; @@ -149,6 +149,8 @@ export interface IAgentHostSessionHandlerConfig { readonly connection: IAgentConnection; /** Sanitized connection authority for constructing vscode-agent-host:// URIs. */ readonly connectionAuthority: string; + /** Shared client state manager that tracks both root and session state. */ + readonly clientState: SessionClientState; /** Extension identifier for the registered agent. Defaults to 'vscode.agent-host'. */ readonly extensionId?: string; /** Extension display name for the registered agent. Defaults to 'Agent Host'. */ @@ -162,8 +164,11 @@ export interface IAgentHostSessionHandlerConfig { * Optional callback invoked when the server rejects an operation because * authentication is required. Should trigger interactive authentication * and return true if the user authenticated successfully. + * + * @param protectedResources The protected resources from the agent's root + * state that require authentication. */ - readonly resolveAuthentication?: () => Promise; + readonly resolveAuthentication?: (protectedResources: IProtectedResourceMetadata[]) => Promise; /** * Observable set of agent-level customizations to include in the active @@ -187,7 +192,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private readonly _clientDispatchedTurnIds = new Set(); private readonly _config: IAgentHostSessionHandlerConfig; - /** Client state manager shared across all sessions for this handler. */ + /** Client state manager shared with the parent contribution. */ private readonly _clientState: SessionClientState; constructor( @@ -202,9 +207,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC ) { super(); this._config = config; - - // Create shared client state manager for this handler instance - this._clientState = this._register(new SessionClientState(config.connection.clientId, this._logService, () => config.connection.nextClientSeq())); + this._clientState = config.clientState; // Register an editing session provider for this handler's session type this._register(this._chatEditingService.registerEditingSessionProvider( @@ -220,13 +223,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC }, )); - // Forward action envelopes from IPC to client state - this._register(config.connection.onDidAction(envelope => { - if (isSessionAction(envelope.action)) { - this._clientState.receiveEnvelope(envelope); - } - })); - // When the customizations observable changes, re-dispatch // activeClientChanged for sessions where this client is already // the active client. This avoids overwriting another client's @@ -1363,6 +1359,19 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._logService.trace(`[AgentHost] Creating new session, model=${rawModelId ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); + // Eagerly authenticate before creating the session if the agent + // declares required protected resources. This avoids a wasted + // round-trip where createSession fails with AuthRequired. + const agentInfo = this._clientState.rootState?.agents.find(a => a.provider === this._config.provider); + const protectedResources = agentInfo?.protectedResources ?? []; + const hasRequiredAuth = protectedResources.some(r => r.required !== false); + if (hasRequiredAuth && this._config.resolveAuthentication) { + const authenticated = await this._config.resolveAuthentication(protectedResources); + if (!authenticated) { + throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again.")); + } + } + let session: URI; try { session = await this._config.connection.createSession({ @@ -1372,10 +1381,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC fork, }); } catch (err) { - // If authentication is required, try to resolve it and retry once + // If authentication is required (e.g. token expired), try interactive auth and retry once if (this._isAuthRequiredError(err) && this._config.resolveAuthentication) { this._logService.info('[AgentHost] Authentication required, prompting user...'); - const authenticated = await this._config.resolveAuthentication(); + const authenticated = await this._config.resolveAuthentication(protectedResources); if (authenticated) { session = await this._config.connection.createSession({ model: rawModelId, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index 7aa234364b9ac..8a010e7fe36f8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -7,7 +7,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../../../base/common/uri.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; -import { IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentConnection, IAgentCreateSessionConfig, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; @@ -101,22 +101,10 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti // ---- IAgentConnection method proxies with logging ----------------------- - async listAgents(): Promise { - return this._logCall('listAgents', undefined, () => this._inner.listAgents()); - } - - async getResourceMetadata(): Promise { - return this._logCall('getResourceMetadata', undefined, () => this._inner.getResourceMetadata()); - } - async authenticate(params: IAuthenticateParams): Promise { return this._logCall('authenticate', params, () => this._inner.authenticate(params)); } - async refreshModels(): Promise { - return this._logCall('refreshModels', undefined, () => this._inner.refreshModels()); - } - async listSessions(): Promise { return this._logCall('listSessions', undefined, () => this._inner.listSessions()); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index 94f513e7741a9..942db76b1acea 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -85,8 +85,7 @@ type AgentStatusSettingMode = 'hidden' | 'badge' | 'compact'; function shouldForceHiddenAgentStatus(configurationService: IConfigurationService): boolean { const aiFeaturesDisabled = configurationService.getValue(ChatConfiguration.AIDisabled) === true; const aiCustomizationsDisabled = configurationService.getValue('disableAICustomizations') === true - || configurationService.getValue('workbench.disableAICustomizations') === true - || configurationService.getValue(ChatConfiguration.ChatCustomizationMenuEnabled) === false; + || configurationService.getValue('workbench.disableAICustomizations') === true; return aiFeaturesDisabled && aiCustomizationsDisabled; } @@ -233,7 +232,6 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { || e.affectsConfiguration(ChatConfiguration.UnifiedAgentsBar) || e.affectsConfiguration(ChatConfiguration.ChatViewSessionsEnabled) || e.affectsConfiguration(ChatConfiguration.AIDisabled) - || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) || e.affectsConfiguration(ChatConfiguration.SignInTitleBarEnabled) || e.affectsConfiguration('disableAICustomizations') || e.affectsConfiguration('workbench.disableAICustomizations') @@ -1461,7 +1459,6 @@ export class AgentTitleBarStatusRendering extends Disposable implements IWorkben e.affectsConfiguration(ChatConfiguration.AgentStatusEnabled) || e.affectsConfiguration(LayoutSettings.COMMAND_CENTER) || e.affectsConfiguration(ChatConfiguration.AIDisabled) - || e.affectsConfiguration(ChatConfiguration.ChatCustomizationMenuEnabled) || e.affectsConfiguration('disableAICustomizations') || e.affectsConfiguration('workbench.disableAICustomizations') ) { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index e54f3fd4c0aec..af62a46c44a09 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -32,7 +32,6 @@ import { EditorInput } from '../../../../common/editor/editorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; -import { ChatConfiguration } from '../../common/constants.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; @@ -684,10 +683,10 @@ class AICustomizationManagementActionsContribution extends Disposable implements constructor() { super({ id: AICustomizationManagementCommands.OpenEditor, - title: localize2('openAICustomizations', "Open Customizations (Preview)"), - shortTitle: localize2('aiCustomizations', "Customizations (Preview)"), + title: localize2('openAICustomizations', "Open Customizations"), + shortTitle: localize2('aiCustomizations', "Customizations"), category: CHAT_CATEGORY, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + precondition: ChatContextKeys.enabled, f1: true, }); } @@ -709,7 +708,7 @@ class AICustomizationManagementActionsContribution extends Disposable implements id: AICustomizationManagementCommands.GenerateDebugReport, title: localize2('generateDebugReport', "Generate Customization Debug Report"), category: Categories.Developer, - precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + precondition: ChatContextKeys.enabled, f1: true, }); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f22dae961cb55..32ee9a467296d 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1393,12 +1393,7 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, - [ChatConfiguration.ChatCustomizationMenuEnabled]: { - type: 'boolean', - tags: ['preview'], - description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is enabled. When enabled, the gear icon in the Chat view opens the Customizations editor directly and additional actions are moved to the overflow menu. When disabled, the gear icon shows the legacy configuration dropdown."), - default: true, - }, + [ChatConfiguration.ChatCustomizationHarnessSelectorEnabled]: { type: 'boolean', tags: ['preview'], diff --git a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts index 96618b9afc43c..3aa02ecc0663c 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/toolSetsContribution.ts @@ -37,7 +37,6 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatViewId } from '../chat.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatConfiguration } from '../../common/constants.js'; const toolEnumValues: string[] = []; @@ -332,7 +331,7 @@ export class ConfigureToolSets extends Action2 { }, { id: MenuId.ViewTitle, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId), ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), order: 11, group: '2_level' }], diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index f9661df87b897..e8270c38fda23 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -57,7 +57,7 @@ export enum ChatConfiguration { RevealNextChangeOnResolve = 'chat.editing.revealNextChangeOnResolve', GrowthNotificationEnabled = 'chat.growthNotification.enabled', SignInTitleBarEnabled = 'chat.signInTitleBar.enabled', - ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled', + ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', AutopilotEnabled = 'chat.autopilot.enabled', ImageCarouselEnabled = 'imageCarousel.chat.enabled', diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index aa3873a1646bd..fefda8e35c762 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -16,6 +16,7 @@ import { timeout } from '../../../../../../base/common/async.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { SessionClientState } from '../../../../../../platform/agentHost/common/state/sessionClientState.js'; import type { IActionEnvelope, INotification, ISessionAction, IToolCallConfirmedAction, ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { ICustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -66,12 +67,6 @@ class MockAgentHostService extends mock() { return [...this._sessions.values()]; } - override async listAgents() { - return this.agents; - } - - override async refreshModels(): Promise { } - override async createSession(config?: IAgentCreateSessionConfig): Promise { if (config) { this.createSessionCalls.push(config); @@ -220,6 +215,8 @@ function createTestServices(disposables: DisposableStore) { function createContribution(disposables: DisposableStore) { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', agentHostService, undefined)); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, @@ -229,6 +226,7 @@ function createContribution(disposables: DisposableStore) { description: 'Copilot SDK agent running in a dedicated process', connection: agentHostService, connectionAuthority: 'local', + clientState, })); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); @@ -1446,6 +1444,8 @@ suite('AgentHostChatContribution', () => { test('handler uses custom extensionId from config', async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'remote-test-copilot', @@ -1454,6 +1454,7 @@ suite('AgentHostChatContribution', () => { description: 'Remote agent', connection: agentHostService, connectionAuthority: 'local', + clientState, extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', })); @@ -1467,6 +1468,8 @@ suite('AgentHostChatContribution', () => { test('handler defaults extensionId when not provided', async () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'default-ext-test', @@ -1475,6 +1478,7 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', + clientState, })); const registered = chatAgentService.registeredAgents.get('default-ext-test'); @@ -1486,6 +1490,8 @@ suite('AgentHostChatContribution', () => { test('handler uses resolveWorkingDirectory callback', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService } = createTestServices(disposables); + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'workdir-test', @@ -1494,6 +1500,7 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', + clientState, resolveWorkingDirectory: () => URI.file('/custom/working/dir'), })); @@ -1518,6 +1525,8 @@ suite('AgentHostChatContribution', () => { path: '/file/-/home/user/project', }); + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'workdir-agenthost-test', @@ -1526,6 +1535,7 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'my-server', + clientState, resolveWorkingDirectory: () => agentHostUri, })); @@ -1567,6 +1577,8 @@ suite('AgentHostChatContribution', () => { const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); // Create handler with agentHostService as IAgentConnection (not IAgentHostService) + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'connection-test', @@ -1575,6 +1587,7 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', + clientState, })); // Verify it registered an agent @@ -2163,6 +2176,8 @@ suite('AgentHostChatContribution', () => { { uri: 'file:///plugin-a', displayName: 'Plugin A' }, ]); + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'agent-host-copilot', @@ -2171,6 +2186,7 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', + clientState, customizations, })); @@ -2192,6 +2208,8 @@ suite('AgentHostChatContribution', () => { const customizations = observableValue('customizations', []); + const clientState = disposables.add(new SessionClientState(agentHostService.clientId, new NullLogService(), () => agentHostService.nextClientSeq())); + disposables.add(agentHostService.onDidAction(e => clientState.receiveEnvelope(e))); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, agentId: 'agent-host-copilot', @@ -2200,6 +2218,7 @@ suite('AgentHostChatContribution', () => { description: 'test', connection: agentHostService, connectionAuthority: 'local', + clientState, customizations, })); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts index 467d4f995e451..d388e69d0f26b 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts @@ -5,9 +5,8 @@ import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; -import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IMcpGatewayServerInfo, IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; +import { IMcpGatewayServerInfo, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; import { IMcpGatewayResult, IMcpGatewayResultServer, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; @@ -28,7 +27,7 @@ export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { @ILogService private readonly _logService: ILogService, ) { } - async createGateway(inRemote: boolean): Promise { + async createGateway(inRemote: boolean, chatSessionResource?: URI): Promise { this._logService.debug(`[McpGateway][BrowserWorkbench] createGateway requested (inRemote=${inRemote})`); // Browser can only create gateways in remote environment @@ -46,8 +45,10 @@ export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { this._logService.info('[McpGateway][BrowserWorkbench] Creating remote gateway via remote server'); // Use the remote server's gateway service return connection.withChannel(McpGatewayChannelName, async channel => { - const service = ProxyChannel.toService(channel); - const info = await service.createGateway(undefined); + const info = await channel.call<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>( + 'createGateway', + chatSessionResource ? { chatSessionResource: chatSessionResource.toString() } : undefined + ); const servers = reviveServers(info.servers); this._logService.info(`[McpGateway][BrowserWorkbench] Remote gateway created with ${servers.length} server(s)`); @@ -64,7 +65,9 @@ export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { onDidChangeServers, dispose: () => { this._logService.info(`[McpGateway][BrowserWorkbench] Disposing remote gateway: ${info.gatewayId}`); - service.disposeGateway(info.gatewayId); + void channel.call('disposeGateway', info.gatewayId).then(undefined, error => { + this._logService.warn(`[McpGateway][BrowserWorkbench] Failed to dispose remote gateway: ${info.gatewayId}`, error); + }); } }; }); diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts index f97a7a8f92cb9..47f85444a1133 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts @@ -53,8 +53,11 @@ export interface IWorkbenchMcpGatewayService { * @param inRemote Whether to create the gateway in the remote environment. * If true, the gateway is created on the remote server (requires a remote connection). * If false, the gateway is created locally (requires a local Node process, e.g., desktop). + * @param chatSessionResource Optional chat session resource URI to associate with this + * gateway. When provided, MCP tool calls made through this gateway will be associated + * with the chat session, enabling inline elicitation UI instead of notification fallback. * @returns A promise that resolves to the gateway result if successful, * or `undefined` if the requested environment is not available. */ - createGateway(inRemote: boolean): Promise; + createGateway(inRemote: boolean, chatSessionResource?: URI): Promise; } diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts index 172f0650aab84..8f90bc7d6eb0e 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts @@ -11,6 +11,7 @@ import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IMcpGatewayServerDescriptor } from '../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../../../platform/mcp/common/modelContextProtocol.js'; +import { URI } from '../../../../base/common/uri.js'; import { McpServer } from './mcpServer.js'; import { IMcpServer, IMcpService, McpCapability, McpServerCacheState, McpToolVisibility } from './mcpTypes.js'; import { startServerAndWaitForLiveTools } from './mcpTypesUtils.js'; @@ -19,6 +20,7 @@ interface ICallToolForServerArgs { serverId: string; name: string; args: Record; + chatSessionResource?: string; } interface IReadResourceForServerArgs { @@ -172,8 +174,8 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return tools as T; } case 'callToolForServer': { - const { serverId, name, args } = arg as ICallToolForServerArgs; - const result = await this._callToolForServer(serverId, name, args || {}, cancellationToken); + const { serverId, name, args, chatSessionResource } = arg as ICallToolForServerArgs; + const result = await this._callToolForServer(serverId, name, args || {}, chatSessionResource, cancellationToken); return result as T; } case 'listResourcesForServer': { @@ -223,7 +225,7 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return tools; } - private async _callToolForServer(serverId: string, name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { + private async _callToolForServer(serverId: string, name: string, args: Record, chatSessionResource?: string, token: CancellationToken = CancellationToken.None): Promise { this._logService.debug(`[McpGateway][ToolBroker] callToolForServer '${serverId}' tool '${name}' with args: ${JSON.stringify(args)}`); const server = this._getServerById(serverId); @@ -238,7 +240,8 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh throw new Error(`Unknown tool '${name}' on server '${serverId}'`); } - const result = await tool.call(args, undefined, token); + const context = chatSessionResource ? { chatSessionResource: URI.parse(chatSessionResource) } : undefined; + const result = await tool.call(args, context, token); this._logService.debug(`[McpGateway][ToolBroker] Tool '${name}' on '${serverId}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); return result; } diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts index 5bf8d9cde6283..600139eaed4eb 100644 --- a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts @@ -33,18 +33,21 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { this._localPlatformService = ProxyChannel.toService(this._localChannel); } - async createGateway(inRemote: boolean): Promise { + async createGateway(inRemote: boolean, chatSessionResource?: URI): Promise { this._logService.debug(`[McpGateway][Workbench] createGateway requested (inRemote=${inRemote})`); if (inRemote) { - return this._createRemoteGateway(); + return this._createRemoteGateway(chatSessionResource); } else { - return this._createLocalGateway(); + return this._createLocalGateway(chatSessionResource); } } - private async _createLocalGateway(): Promise { + private async _createLocalGateway(chatSessionResource?: URI): Promise { this._logService.info('[McpGateway][Workbench] Creating local gateway via main process'); - const info = await this._localPlatformService.createGateway(undefined); + const info = await this._localChannel.call<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>( + 'createGateway', + chatSessionResource ? { chatSessionResource: chatSessionResource.toString() } : undefined + ); const servers = reviveServers(info.servers); this._logService.info(`[McpGateway][Workbench] Local gateway created with ${servers.length} server(s)`); @@ -66,7 +69,7 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { }; } - private async _createRemoteGateway(): Promise { + private async _createRemoteGateway(chatSessionResource?: URI): Promise { const connection = this._remoteAgentService.getConnection(); if (!connection) { this._logService.info('[McpGateway][Workbench] No remote connection available for remote gateway'); @@ -75,8 +78,10 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { this._logService.info('[McpGateway][Workbench] Creating remote gateway via remote server'); return connection.withChannel(McpGatewayChannelName, async channel => { - const service = ProxyChannel.toService(channel); - const info = await service.createGateway(undefined); + const info = await channel.call<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>( + 'createGateway', + chatSessionResource ? { chatSessionResource: chatSessionResource.toString() } : undefined + ); const servers = reviveServers(info.servers); this._logService.info(`[McpGateway][Workbench] Remote gateway created with ${servers.length} server(s)`); @@ -93,7 +98,9 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { onDidChangeServers, dispose: () => { this._logService.info(`[McpGateway][Workbench] Disposing remote gateway: ${info.gatewayId}`); - service.disposeGateway(info.gatewayId); + void channel.call('disposeGateway', info.gatewayId).catch(error => { + this._logService.warn(`[McpGateway][Workbench] Failed to dispose remote gateway: ${info.gatewayId}`, error); + }); } }; }); diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index be81ff88ad31b..6a41246aa967d 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -6,6 +6,7 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ContributionEnablementState } from '../../../chat/common/enablement.js'; @@ -13,7 +14,7 @@ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { IMcpGatewayServerDescriptor } from '../../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../common/modelContextProtocol.js'; import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js'; -import { IMcpIcons, IMcpServer, IMcpTool, McpConnectionState, McpServerCacheState, McpToolVisibility } from '../../common/mcpTypes.js'; +import { IMcpIcons, IMcpServer, IMcpTool, IMcpToolCallContext, McpConnectionState, McpServerCacheState, McpToolVisibility } from '../../common/mcpTypes.js'; import { TestMcpService } from './testMcpService.js'; suite('McpGatewayToolBrokerChannel', () => { @@ -269,6 +270,55 @@ suite('McpGatewayToolBrokerChannel', () => { channel.dispose(); }); + test('forwards chatSessionResource as tool call context', async () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); + + const receivedContexts: (IMcpToolCallContext | undefined)[] = []; + const server = createServer('collectionA', 'serverA', [ + createToolWithContextCapture('echo', receivedContexts, async () => ({ content: [{ type: 'text', text: 'ok' }] })), + ]); + + mcpService.servers.set([server], undefined); + + const sessionUri = 'vscode-chat-session://test/session-123'; + await channel.call(undefined, 'callToolForServer', { + serverId: 'serverA', + name: 'echo', + args: { input: 'hello' }, + chatSessionResource: sessionUri, + }); + + assert.strictEqual(receivedContexts.length, 1); + assert.ok(receivedContexts[0]); + assert.strictEqual(receivedContexts[0]!.chatSessionResource!.toString(), URI.parse(sessionUri).toString()); + + channel.dispose(); + }); + + test('passes undefined context when chatSessionResource is omitted', async () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); + + const receivedContexts: (IMcpToolCallContext | undefined)[] = []; + const server = createServer('collectionA', 'serverA', [ + createToolWithContextCapture('echo', receivedContexts, async () => ({ content: [{ type: 'text', text: 'ok' }] })), + ]); + + mcpService.servers.set([server], undefined); + + await channel.call(undefined, 'callToolForServer', { + serverId: 'serverA', + name: 'echo', + args: { input: 'hello' }, + }); + + assert.strictEqual(receivedContexts.length, 1); + assert.strictEqual(receivedContexts[0], undefined); + + channel.dispose(); + }); + test('emits onDidChangeServers with descriptors when servers change', () => { const mcpService = new TestMcpService(); const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); @@ -389,6 +439,36 @@ function createNeverStartingServer( return result; } +function createToolWithContextCapture( + name: string, + receivedContexts: (IMcpToolCallContext | undefined)[], + call: (params: Record) => Promise, + visibility: McpToolVisibility = McpToolVisibility.Model, +): IMcpTool { + const definition: MCP.Tool = { + name, + description: `Tool ${name}`, + inputSchema: { type: 'object', properties: { input: { type: 'string' } } }, + }; + + return { + id: `tool_${name}`, + referenceName: name, + icons: {} as IMcpIcons, + definition, + visibility, + uiResourceUri: undefined, + call: (params: Record, context, _token) => { + receivedContexts.push(context); + return call(params); + }, + callWithProgress: (params: Record, _progress, context, _token = CancellationToken.None) => { + receivedContexts.push(context); + return call(params); + }, + }; +} + function createTool(name: string, call: (params: Record) => Promise, visibility: McpToolVisibility = McpToolVisibility.Model): IMcpTool { const definition: MCP.Tool = { name, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index ea341825e4e25..f2a00eee9060e 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -661,6 +661,9 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary() { - override windowId = mainWindow.vscodeWindowId; - }(); -} - -function toExtensionInfo(file: IFixtureFile): { identifier: ExtensionIdentifier; displayName?: string } | undefined { - if (!file.extensionId) { - return undefined; - } - - return { - identifier: new ExtensionIdentifier(file.extensionId), - displayName: file.extensionDisplayName, - }; -} - -function createMockPromptsService(files: IFixtureFile[], agentInstructions: IAgentInstructionFile[]): IPromptsService { - const applyToMap = new ResourceMap(); - const descriptionMap = new ResourceMap(); - for (const f of files) { applyToMap.set(f.uri, f.applyTo); descriptionMap.set(f.uri, f.description); } - return new class extends mock() { - override readonly onDidChangeCustomAgents = Event.None; - override readonly onDidChangeSlashCommands = Event.None; - override readonly onDidChangeSkills = Event.None; - override readonly onDidChangeInstructions = Event.None; - override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } - override async listPromptFiles(type: PromptsType, _token: CancellationToken) { - return files.filter(f => f.type === type).map(f => ({ - uri: f.uri, - storage: f.storage as PromptsStorage.local, - type: f.type, - name: f.name, - description: f.description, - extension: toExtensionInfo(f) as never, - })); - } - override async listAgentInstructions() { return agentInstructions; } - override async getCustomAgents() { - return files.filter(f => f.type === PromptsType.agent).map(a => ({ - uri: a.uri, name: a.name ?? 'agent', description: a.description, storage: a.storage, - source: { - storage: a.storage, - extensionId: a.extensionId ? new ExtensionIdentifier(a.extensionId) : undefined, - }, - })) as never[]; - } - override async parseNew(uri: URI, _token: CancellationToken): Promise { - const header = { - get applyTo() { return applyToMap.get(uri); }, - get description() { return descriptionMap.get(uri); }, - }; - return new ParsedPromptFile(uri, header as never); - } - override async getSourceFolders() { return [] as never[]; } - override async findAgentSkills(): Promise { - return files.filter(f => f.type === PromptsType.skill).map(f => ({ - uri: f.uri, - storage: f.storage, - name: f.name ?? 'skill', - description: f.description, - disableModelInvocation: false, - userInvocable: true, - when: undefined, - })); - } - override async getPromptSlashCommands(): Promise { - const promptFiles = files.filter(f => f.type === PromptsType.prompt); - const commands = await Promise.all(promptFiles.map(async f => { - return { - uri: f.uri, - userInvocable: true, - name: f.name ?? 'prompt', - description: f.description, - argumentHint: undefined, - type: f.type, - storage: f.storage, - source: undefined, - extension: toExtensionInfo(f) as never, - when: undefined, - } satisfies IChatPromptSlashCommand; - })); - return commands; - } - }(); -} - -function createMockHarnessService(activeHarness: CustomizationHarness, descriptors: readonly IHarnessDescriptor[]): ICustomizationHarnessService { - const active = observableValue('activeHarness', activeHarness); - return new class extends mock() { - override readonly activeHarness = active; - override readonly availableHarnesses = constObservable(descriptors); - override getStorageSourceFilter(type: PromptsType) { - const d = descriptors.find(h => h.id === active.get()) ?? descriptors[0]; - return d.getStorageSourceFilter(type); - } - override getActiveDescriptor() { - return descriptors.find(h => h.id === active.get()) ?? descriptors[0]; - } - override setActiveHarness(id: string) { active.set(id, undefined); } - override registerExternalHarness() { return { dispose() { } }; } - }(); -} - -function makeLocalMcpServer(id: string, label: string, scope: LocalMcpServerScope, description?: string): IWorkbenchMcpServer { - return new class extends mock() { - override readonly id = id; - override readonly name = id; - override readonly label = label; - override readonly description = description ?? ''; - override readonly installState = McpServerInstallState.Installed; - override readonly local = new class extends mock() { - override readonly id = id; - override readonly scope = scope; - }(); - }(); -} - -function createMockAgentFeedbackService(): IAgentFeedbackService { - return new class extends mock() { - override readonly onDidChangeFeedback = Event.None; - override readonly onDidChangeNavigation = Event.None; - override getFeedback() { return []; } - override getMostRecentSessionForResource() { return undefined; } - override async revealFeedback(): Promise { } - override getNextFeedback() { return undefined; } - override getNavigationBearing() { return { activeIdx: -1, totalCount: 0 }; } - override getNextNavigableItem() { return undefined; } - override setNavigationAnchor(): void { } - override clearFeedback(): void { } - override removeFeedback(): void { } - override async addFeedbackAndSubmit(): Promise { } - }(); -} - -// ============================================================================ -// Realistic test data — a project that has Copilot + Claude customizations -// ============================================================================ - -const allFiles: IFixtureFile[] = [ - // Instructions - extension (built-in + third-party) - { uri: URI.file('/extensions/github.copilot-chat/instructions/coding.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, name: 'Copilot Coding', description: 'Built-in coding guidance', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, - { uri: URI.file('/extensions/acme.tools/instructions/team.instructions.md'), storage: PromptsStorage.extension, type: PromptsType.instructions, name: 'Team Conventions', description: 'Third-party extension instructions', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, - // Instructions — workspace - { uri: URI.file('/workspace/.github/instructions/coding-standards.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Coding Standards', description: 'Repository-wide coding standards' }, - { uri: URI.file('/workspace/.github/instructions/testing.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Testing best practices', applyTo: '**/*.test.ts' }, - { uri: URI.file('/workspace/.github/instructions/security.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Security', description: 'Security review checklist', applyTo: 'src/auth/**' }, - { uri: URI.file('/workspace/.github/instructions/accessibility.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Accessibility', description: 'WCAG compliance guidelines', applyTo: '**/*.tsx' }, - { uri: URI.file('/workspace/.github/instructions/api-design.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'API Design', description: 'REST API design conventions' }, - { uri: URI.file('/workspace/.github/instructions/performance.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Performance', description: 'Performance optimization rules', applyTo: 'src/core/**' }, - { uri: URI.file('/workspace/.github/instructions/error-handling.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Error Handling', description: 'Error handling patterns' }, - { uri: URI.file('/workspace/.github/instructions/database.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Database', description: 'Database migration and query patterns', applyTo: 'src/db/**' }, - // Instructions — user - { uri: URI.file('/home/dev/.copilot/instructions/my-style.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'My Style', description: 'Personal coding style' }, - { uri: URI.file('/home/dev/.copilot/instructions/typescript-rules.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'TypeScript Rules', description: 'Strict TypeScript conventions' }, - { uri: URI.file('/home/dev/.copilot/instructions/commit-messages.instructions.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Commit Messages', description: 'Conventional commit format' }, - // Instructions — Claude rules - { uri: URI.file('/workspace/.claude/rules/code-style.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Code Style', description: 'Claude code style rules' }, - { uri: URI.file('/workspace/.claude/rules/testing.md'), storage: PromptsStorage.local, type: PromptsType.instructions, name: 'Testing', description: 'Claude testing conventions' }, - { uri: URI.file('/home/dev/.claude/rules/personal.md'), storage: PromptsStorage.user, type: PromptsType.instructions, name: 'Personal', description: 'Personal rules' }, - // Agents — workspace - { uri: URI.file('/workspace/.github/agents/reviewer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Reviewer', description: 'Code review agent' }, - { uri: URI.file('/workspace/.github/agents/documenter.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Documenter', description: 'Documentation agent' }, - { uri: URI.file('/workspace/.github/agents/tester.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Tester', description: 'Test generation and validation' }, - { uri: URI.file('/workspace/.github/agents/refactorer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Refactorer', description: 'Code refactoring specialist' }, - { uri: URI.file('/workspace/.github/agents/security-auditor.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Security Auditor', description: 'Security vulnerability scanner' }, - { uri: URI.file('/workspace/.github/agents/api-designer.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'API Designer', description: 'REST and GraphQL API design' }, - { uri: URI.file('/workspace/.github/agents/performance-tuner.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent, name: 'Performance Tuner', description: 'Performance profiling and optimization' }, - // Agents — user - { uri: URI.file('/home/dev/.copilot/agents/planner.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Planner', description: 'Project planning agent' }, - { uri: URI.file('/home/dev/.copilot/agents/debugger.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'Debugger', description: 'Interactive debugging assistant' }, - { uri: URI.file('/home/dev/.copilot/agents/nls-helper.agent.md'), storage: PromptsStorage.user, type: PromptsType.agent, name: 'NLS Helper', description: 'Natural language searching code for clarity' }, - // Agents - extension (built-in + third-party) - { uri: URI.file('/extensions/github.copilot-chat/agents/workspace-guide.agent.md'), storage: PromptsStorage.extension, type: PromptsType.agent, name: 'Workspace Guide', description: 'Built-in workspace exploration agent', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, - { uri: URI.file('/extensions/acme.tools/agents/api-helper.agent.md'), storage: PromptsStorage.extension, type: PromptsType.agent, name: 'API Helper', description: 'Third-party API agent', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, - // Skills — workspace - { uri: URI.file('/workspace/.github/skills/deploy/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Deploy', description: 'Deployment automation' }, - { uri: URI.file('/workspace/.github/skills/refactor/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Refactor', description: 'Code refactoring patterns' }, - { uri: URI.file('/workspace/.github/skills/unit-tests/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Unit Tests', description: 'Test generation and runner integration' }, - { uri: URI.file('/workspace/.github/skills/ci-fix/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'CI Fix', description: 'Diagnose and fix CI failures' }, - { uri: URI.file('/workspace/.github/skills/migration/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Migration', description: 'Database migration generation' }, - { uri: URI.file('/workspace/.github/skills/accessibility/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Accessibility', description: 'ARIA labels and keyboard navigation' }, - { uri: URI.file('/workspace/.github/skills/docker/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'Docker', description: 'Dockerfile and compose generation' }, - { uri: URI.file('/workspace/.github/skills/api-docs/SKILL.md'), storage: PromptsStorage.local, type: PromptsType.skill, name: 'API Docs', description: 'OpenAPI spec generation' }, - // Skills — user - { uri: URI.file('/home/dev/.copilot/skills/git-workflow/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Git Workflow', description: 'Branch and PR workflows' }, - { uri: URI.file('/home/dev/.copilot/skills/code-review/SKILL.md'), storage: PromptsStorage.user, type: PromptsType.skill, name: 'Code Review', description: 'Structured code review checklist' }, - // Skills - extension (built-in + third-party) - { uri: URI.file('/extensions/github.copilot-chat/skills/workspace/SKILL.md'), storage: PromptsStorage.extension, type: PromptsType.skill, name: 'Workspace Search', description: 'Built-in workspace search skill', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, - { uri: URI.file('/extensions/acme.tools/skills/audit/SKILL.md'), storage: PromptsStorage.extension, type: PromptsType.skill, name: 'Audit', description: 'Third-party audit skill', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, - // Skills - built-in (sessions bundled skills with UI integrations) - { uri: URI.file('/app/skills/act-on-feedback/SKILL.md'), storage: BUILTIN_STORAGE as PromptsStorage, type: PromptsType.skill, name: 'act-on-feedback', description: 'Act on user feedback attached to the current session' }, - { uri: URI.file('/app/skills/generate-run-commands/SKILL.md'), storage: BUILTIN_STORAGE as PromptsStorage, type: PromptsType.skill, name: 'generate-run-commands', description: 'Generate or modify run commands for the current session' }, - { uri: URI.file('/app/skills/commit/SKILL.md'), storage: BUILTIN_STORAGE as PromptsStorage, type: PromptsType.skill, name: 'commit', description: 'Commit staged or unstaged changes with an AI-generated commit message' }, - { uri: URI.file('/app/skills/create-pr/SKILL.md'), storage: BUILTIN_STORAGE as PromptsStorage, type: PromptsType.skill, name: 'create-pr', description: 'Create a pull request for the current session' }, - // Prompts — workspace - { uri: URI.file('/workspace/.github/prompts/explain.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Explain', description: 'Explain selected code' }, - { uri: URI.file('/workspace/.github/prompts/review.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Review', description: 'Review changes' }, - { uri: URI.file('/workspace/.github/prompts/fix-bug.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Fix Bug', description: 'Diagnose and fix a bug from issue' }, - { uri: URI.file('/workspace/.github/prompts/write-tests.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Write Tests', description: 'Generate unit tests for selection' }, - { uri: URI.file('/workspace/.github/prompts/add-docs.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Add Docs', description: 'Add JSDoc comments to functions' }, - { uri: URI.file('/workspace/.github/prompts/optimize.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Optimize', description: 'Optimize code for performance' }, - { uri: URI.file('/workspace/.github/prompts/convert-to-ts.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Convert to TS', description: 'Convert JavaScript to TypeScript' }, - { uri: URI.file('/workspace/.github/prompts/summarize-pr.prompt.md'), storage: PromptsStorage.local, type: PromptsType.prompt, name: 'Summarize PR', description: 'Generate PR description from diff' }, - // Prompts — user - { uri: URI.file('/home/dev/.copilot/prompts/translate.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Translate', description: 'Translate strings for i18n' }, - { uri: URI.file('/home/dev/.copilot/prompts/commit-msg.prompt.md'), storage: PromptsStorage.user, type: PromptsType.prompt, name: 'Commit Message', description: 'Generate conventional commit' }, - // Prompts - extension (built-in + third-party) - { uri: URI.file('/extensions/github.copilot-chat/prompts/trace.prompt.md'), storage: PromptsStorage.extension, type: PromptsType.prompt, name: 'Trace', description: 'Built-in tracing prompt', extensionId: 'GitHub.copilot-chat', extensionDisplayName: 'GitHub Copilot Chat' }, - { uri: URI.file('/extensions/acme.tools/prompts/lint.prompt.md'), storage: PromptsStorage.extension, type: PromptsType.prompt, name: 'Lint', description: 'Third-party lint prompt', extensionId: 'acme.tools', extensionDisplayName: 'Acme Tools' }, - // Hooks — workspace - { uri: URI.file('/workspace/.github/hooks/pre-commit.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Commit Lint', description: 'Run linting before commit' }, - { uri: URI.file('/workspace/.github/hooks/post-save.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Save Format', description: 'Auto-format on save' }, - { uri: URI.file('/workspace/.github/hooks/on-test-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Test Failure', description: 'Suggest fix when tests fail' }, - { uri: URI.file('/workspace/.github/hooks/pre-push.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Pre-Push Check', description: 'Run type-check before push' }, - { uri: URI.file('/workspace/.github/hooks/post-create.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post-Create', description: 'Initialize boilerplate for new files' }, - { uri: URI.file('/workspace/.github/hooks/on-error.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Error', description: 'Log and report unhandled errors' }, - { uri: URI.file('/workspace/.github/hooks/post-tool-call.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'Post Tool Call', description: 'Echo confirmation after each tool call' }, - { uri: URI.file('/workspace/.github/hooks/on-build-fail.json'), storage: PromptsStorage.local, type: PromptsType.hook, name: 'On Build Failure', description: 'Auto-diagnose build errors' }, - // Hooks — user - { uri: URI.file('/home/dev/.copilot/hooks/daily-summary.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Daily Summary', description: 'Generate daily work summary' }, - { uri: URI.file('/home/dev/.copilot/hooks/backup-changes.json'), storage: PromptsStorage.user, type: PromptsType.hook, name: 'Backup Changes', description: 'Auto-stash uncommitted changes' }, -]; - -const agentInstructions: IAgentInstructionFile[] = [ - { uri: URI.file('/workspace/AGENTS.md'), realPath: undefined, type: AgentInstructionFileType.agentsMd }, - { uri: URI.file('/workspace/CLAUDE.md'), realPath: undefined, type: AgentInstructionFileType.claudeMd }, - { uri: URI.file('/workspace/.github/copilot-instructions.md'), realPath: undefined, type: AgentInstructionFileType.copilotInstructionsMd }, -]; - -const mcpWorkspaceServers = [ - makeLocalMcpServer('mcp-postgres', 'PostgreSQL', LocalMcpServerScope.Workspace, 'Database access'), - makeLocalMcpServer('mcp-github', 'GitHub', LocalMcpServerScope.Workspace, 'GitHub API'), - makeLocalMcpServer('mcp-redis', 'Redis', LocalMcpServerScope.Workspace, 'In-memory data store'), - makeLocalMcpServer('mcp-docker', 'Docker', LocalMcpServerScope.Workspace, 'Container management'), - makeLocalMcpServer('mcp-slack', 'Slack', LocalMcpServerScope.Workspace, 'Team messaging'), - makeLocalMcpServer('mcp-jira', 'Jira', LocalMcpServerScope.Workspace, 'Issue tracking'), - makeLocalMcpServer('mcp-aws', 'AWS', LocalMcpServerScope.Workspace, 'Amazon Web Services'), - makeLocalMcpServer('mcp-graphql', 'GraphQL', LocalMcpServerScope.Workspace, 'GraphQL API gateway'), -]; -const mcpUserServers = [ - makeLocalMcpServer('mcp-web-search', 'Web Search', LocalMcpServerScope.User, 'Search the web'), - makeLocalMcpServer('mcp-filesystem', 'Filesystem', LocalMcpServerScope.User, 'Local file operations'), - makeLocalMcpServer('mcp-puppeteer', 'Puppeteer', LocalMcpServerScope.User, 'Browser automation'), -]; -const mcpRuntimeServers = [ - { definition: { id: 'github-copilot-mcp', label: 'GitHub Copilot' }, collection: { id: 'ext.github.copilot/mcp', label: 'ext.github.copilot/mcp' }, enablement: constObservable(2), connectionState: constObservable({ state: 2 }) }, -]; - -interface IRenderEditorOptions { - readonly harness: CustomizationHarness; - readonly isSessionsWindow?: boolean; - readonly managementSections?: readonly AICustomizationManagementSection[]; - readonly availableHarnesses?: readonly IHarnessDescriptor[]; - readonly selectedSection?: AICustomizationManagementSection; - readonly scrollToBottom?: boolean; - readonly width?: number; - readonly height?: number; - readonly skillUIIntegrations?: ReadonlyMap; -} - -async function waitForAnimationFrames(count: number): Promise { - for (let i = 0; i < count; i++) { - await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); - } -} - -function getVisibleEditorSignature(container: HTMLElement): string { - const sectionCounts = [...container.querySelectorAll('.section-list-item')].map(item => item.textContent?.replace(/\s+/g, ' ').trim() ?? '').join('|'); - const visibleContent = [...container.querySelectorAll('.prompts-content-container, .mcp-content-container, .plugin-content-container')] - .find(node => node instanceof HTMLElement && node.style.display !== 'none'); - const visibleRows = visibleContent - ? [...visibleContent.querySelectorAll('.monaco-list-row')].map(row => row.textContent?.replace(/\s+/g, ' ').trim() ?? '').join('|') - : ''; - - return `${sectionCounts}@@${visibleRows}`; -} - -async function waitForEditorToSettle(container: HTMLElement): Promise { - let previousSignature = ''; - let stableIterations = 0; - - await new Promise(resolve => setTimeout(resolve, 150)); - - for (let i = 0; i < 20; i++) { - await waitForAnimationFrames(2); - await new Promise(resolve => setTimeout(resolve, 25)); - - const signature = getVisibleEditorSignature(container); - if (signature && signature === previousSignature) { - stableIterations++; - if (stableIterations >= 2) { - return; - } - } else { - stableIterations = 0; - previousSignature = signature; - } - } -} - -async function waitForVisibleScrollbarsToFade(container: HTMLElement): Promise { - const deadline = Date.now() + 4000; - - while (Date.now() < deadline) { - const hasVisibleScrollbar = [...container.querySelectorAll('.scrollbar.vertical')].some(scrollbar => { - const style = mainWindow.getComputedStyle(scrollbar); - return scrollbar.classList.contains('visible') && style.opacity !== '0'; - }); - - if (!hasVisibleScrollbar) { - return; - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - -// ============================================================================ -// Render helper — creates the full management editor -// ============================================================================ - -async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditorOptions): Promise { - const width = options.width ?? 900; - const height = options.height ?? 600; - ctx.container.style.width = `${width}px`; - ctx.container.style.height = `${height}px`; - - const isSessionsWindow = options.isSessionsWindow ?? false; - const skillUIIntegrations = options.skillUIIntegrations ?? new Map(); - const managementSections = options.managementSections ?? [ - AICustomizationManagementSection.Agents, - AICustomizationManagementSection.Skills, - AICustomizationManagementSection.Instructions, - AICustomizationManagementSection.Hooks, - AICustomizationManagementSection.Prompts, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, - ]; - const availableHarnesses = options.availableHarnesses ?? [ - createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]), - createCliHarnessDescriptor(getCliUserRoots(userHome), []), - createClaudeHarnessDescriptor(getClaudeUserRoots(userHome), []), - ]; - - const allMcpServers = [...mcpWorkspaceServers, ...mcpUserServers]; - - const instantiationService = createEditorServices(ctx.disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - const harnessService = createMockHarnessService(options.harness, availableHarnesses); - const agentFeedbackService = createMockAgentFeedbackService(); - const codeReviewService = createMockCodeReviewService(); - registerWorkbenchServices(reg); - reg.define(IListService, ListService); - reg.defineInstance(IAgentFeedbackService, agentFeedbackService); - reg.defineInstance(ICodeReviewService, codeReviewService); - reg.defineInstance(IChatEditingService, new class extends mock() { - override readonly editingSessionsObs = constObservable([]); - }()); - reg.defineInstance(IAgentSessionsService, new class extends mock() { - override readonly model = new class extends mock() { - override readonly sessions = []; - }(); - override getSession() { return undefined; } - }()); - reg.defineInstance(IPromptsService, createMockPromptsService(allFiles, agentInstructions)); - reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { - override readonly isSessionsWindow = isSessionsWindow; - override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); - override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); - override getActiveProjectRoot() { return URI.file('/workspace'); } - override getStorageSourceFilter(type: PromptsType) { return harnessService.getStorageSourceFilter(type); } - override clearOverrideProjectRoot() { } - override setOverrideProjectRoot() { } - override readonly managementSections = managementSections; - override async generateCustomization() { } - override getSkillUIIntegrations() { return skillUIIntegrations; } - }()); - reg.defineInstance(ICustomizationHarnessService, harnessService); - reg.defineInstance(IChatSessionsService, new class extends mock() { - override readonly onDidChangeCustomizations = Event.None; - override async getCustomizations() { return undefined; } - override getRegisteredChatSessionItemProviders() { return []; } - override hasCustomizationsProvider() { return false; } - }()); - reg.defineInstance(IWorkspaceContextService, new class extends mock() { - override readonly onDidChangeWorkspaceFolders = Event.None; - override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } - override getWorkbenchState(): WorkbenchState { return WorkbenchState.WORKSPACE; } - }()); - reg.defineInstance(IFileService, new class extends mock() { - override readonly onDidFilesChange = Event.None; - }()); - reg.defineInstance(IPathService, new class extends mock() { - override readonly defaultUriScheme = 'file'; - override userHome(): URI; - override userHome(): Promise; - override userHome(): URI | Promise { return userHome; } - }()); - reg.defineInstance(ITextModelService, new class extends mock() { }()); - reg.defineInstance(IWorkingCopyService, new class extends mock() { - override readonly onDidChangeDirty = Event.None; - }()); - reg.defineInstance(IFileDialogService, new class extends mock() { }()); - reg.defineInstance(IExtensionService, new class extends mock() { }()); - reg.defineInstance(IQuickInputService, new class extends mock() { }()); - reg.defineInstance(IRequestService, new class extends mock() { }()); - reg.defineInstance(IMarkdownRendererService, new class extends mock() { - override render() { - const rendered: IRenderedMarkdown = { - element: DOM.$('span'), - dispose() { }, - }; - return rendered; - } - }()); - reg.defineInstance(IWebviewService, new class extends mock() { }()); - reg.defineInstance(IMcpWorkbenchService, new class extends mock() { - override readonly onChange = Event.None; - override readonly onReset = Event.None; - override readonly local = allMcpServers; - override async queryLocal() { return allMcpServers; } - override canInstall() { return true as const; } - }()); - reg.defineInstance(IMcpService, new class extends mock() { - override readonly servers = constObservable(mcpRuntimeServers as never[]); - }()); - reg.defineInstance(IMcpRegistry, new class extends mock() { - override readonly collections = constObservable([]); - override readonly delegates = constObservable([]); - override readonly onDidChangeInputs = Event.None; - }()); - reg.defineInstance(IAgentPluginService, new class extends mock() { - override readonly plugins = constObservable(installedPlugins); - override readonly enablementModel = undefined as never; - }()); - reg.defineInstance(IPluginMarketplaceService, new class extends mock() { - override readonly installedPlugins = constObservable([]); - override readonly onDidChangeMarketplaces = Event.None; - }()); - reg.defineInstance(IPluginInstallService, new class extends mock() { }()); - reg.defineInstance(IProductService, new class extends mock() { - override readonly defaultChatAgent = new class extends mock>() { - override readonly chatExtensionId = 'GitHub.copilot-chat'; - }(); - }()); - }, - }); - - const editor = ctx.disposableStore.add( - instantiationService.createInstance(AICustomizationManagementEditor, createMockEditorGroup()) - ); - editor.create(ctx.container); - editor.layout(new Dimension(width, height)); - - await editor.setInput(AICustomizationManagementEditorInput.getOrCreate(), undefined, {}, CancellationToken.None); - - if (options.selectedSection) { - editor.selectSectionById(options.selectedSection); - } - - await waitForEditorToSettle(ctx.container); - - if (options.scrollToBottom) { - editor.revealLastItem(); - await waitForAnimationFrames(2); - await new Promise(resolve => setTimeout(resolve, 2400)); - await waitForVisibleScrollbarsToFade(ctx.container); - } -} - -// ============================================================================ -// MCP Browse Mode — standalone widget with gallery results -// ============================================================================ - -function makeGalleryServer(id: string, label: string, description: string, publisher: string): IWorkbenchMcpServer { - const galleryStub = new class extends mock>() { }(); - return new class extends mock() { - override readonly id = id; - override readonly name = id; - override readonly label = label; - override readonly description = description; - override readonly publisherDisplayName = publisher; - override readonly installState = McpServerInstallState.Uninstalled; - override readonly gallery = galleryStub; - override readonly local = undefined; - }(); -} - -const galleryServers = [ - makeGalleryServer('gallery-postgres', 'PostgreSQL', 'Access PostgreSQL databases with schema inspection and query tools', 'Microsoft'), - makeGalleryServer('gallery-github', 'GitHub', 'Repository management, issues, pull requests, and code search', 'GitHub'), - makeGalleryServer('gallery-slack', 'Slack', 'Send messages, manage channels, and search workspace history', 'Slack Technologies'), - makeGalleryServer('gallery-docker', 'Docker', 'Container lifecycle management and image operations', 'Docker Inc'), - makeGalleryServer('gallery-filesystem', 'Filesystem', 'Read, write, and navigate local files and directories', 'Microsoft'), - makeGalleryServer('gallery-brave', 'Brave Search', 'Web and local search powered by the Brave Search API', 'Brave Software'), - makeGalleryServer('gallery-puppeteer', 'Puppeteer', 'Browser automation with screenshots, navigation, and form filling', 'Google'), - makeGalleryServer('gallery-memory', 'Memory', 'Knowledge graph for persistent memory across conversations', 'Microsoft'), - makeGalleryServer('gallery-fetch', 'Fetch', 'Retrieve and convert web content to markdown for analysis', 'Microsoft'), - makeGalleryServer('gallery-sentry', 'Sentry', 'Error monitoring, issue tracking, and performance tracing', 'Sentry'), - makeGalleryServer('gallery-sqlite', 'SQLite', 'Query and manage SQLite databases with schema exploration', 'Community'), - makeGalleryServer('gallery-redis', 'Redis', 'In-memory data store operations and key management', 'Redis Ltd'), -]; - -async function renderMcpBrowseMode(ctx: ComponentFixtureContext): Promise { - const width = 650; - const height = 500; - ctx.container.style.width = `${width}px`; - ctx.container.style.height = `${height}px`; - - const instantiationService = createEditorServices(ctx.disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - registerWorkbenchServices(reg); - reg.define(IListService, ListService); - reg.defineInstance(IMcpWorkbenchService, new class extends mock() { - override readonly onChange = Event.None; - override readonly onReset = Event.None; - override readonly local: IWorkbenchMcpServer[] = []; - override async queryLocal() { return []; } - override canInstall() { return true as const; } - override async queryGallery(): Promise> { - return { - firstPage: { items: galleryServers, hasMore: false }, - async getNextPage() { return { items: [], hasMore: false }; }, - }; - } - }()); - reg.defineInstance(IMcpService, new class extends mock() { - override readonly servers = constObservable([] as never[]); - }()); - reg.defineInstance(IMcpRegistry, new class extends mock() { - override readonly collections = constObservable([]); - override readonly delegates = constObservable([]); - override readonly onDidChangeInputs = Event.None; - }()); - reg.defineInstance(IAgentPluginService, new class extends mock() { - override readonly plugins = constObservable([]); - }()); - reg.defineInstance(IDialogService, new class extends mock() { }()); - reg.defineInstance(IAICustomizationWorkspaceService, new class extends mock() { - override readonly isSessionsWindow = false; - override readonly activeProjectRoot = observableValue('root', URI.file('/workspace')); - override readonly hasOverrideProjectRoot = observableValue('hasOverride', false); - override getActiveProjectRoot() { return URI.file('/workspace'); } - override getStorageSourceFilter() { - return { sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension, PromptsStorage.plugin] }; - } - }()); - reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); - override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } - override registerExternalHarness() { return { dispose() { } }; } - }()); - }, - }); - - const widget = ctx.disposableStore.add( - instantiationService.createInstance(McpListWidget) - ); - ctx.container.appendChild(widget.element); - widget.layout(height, width); - - // Click the Browse Marketplace button to enter browse mode - const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; - browseButton?.click(); - - // Wait for the gallery query to resolve - await new Promise(resolve => setTimeout(resolve, 50)); -} - -// ============================================================================ -// Plugin Browse Mode — standalone widget with marketplace results -// ============================================================================ - -function makeInstalledPlugin(name: string, uri: URI, enabled: boolean): IAgentPlugin { - return new class extends mock() { - override readonly uri = uri; - override readonly label = name; - override readonly enablement = constObservable(enabled ? ContributionEnablementState.EnabledProfile : ContributionEnablementState.DisabledProfile); - override readonly hooks = constObservable([]); - override readonly commands = constObservable([]); - override readonly skills = constObservable([]); - override readonly agents = constObservable([]); - override readonly instructions = constObservable([]); - override readonly mcpServerDefinitions = constObservable([]); - override remove() { } - }(); -} - -const installedPlugins: IAgentPlugin[] = [ - makeInstalledPlugin('Linear', URI.file('/workspace/.copilot/plugins/linear'), true), - makeInstalledPlugin('Sentry', URI.file('/workspace/.copilot/plugins/sentry'), true), - makeInstalledPlugin('Datadog', URI.file('/workspace/.copilot/plugins/datadog'), true), - makeInstalledPlugin('Notion', URI.file('/workspace/.copilot/plugins/notion'), true), - makeInstalledPlugin('Confluence', URI.file('/workspace/.copilot/plugins/confluence'), true), - makeInstalledPlugin('PagerDuty', URI.file('/workspace/.copilot/plugins/pagerduty'), false), - makeInstalledPlugin('LaunchDarkly', URI.file('/workspace/.copilot/plugins/launchdarkly'), true), - makeInstalledPlugin('CircleCI', URI.file('/workspace/.copilot/plugins/circleci'), true), - makeInstalledPlugin('Vercel', URI.file('/workspace/.copilot/plugins/vercel'), false), - makeInstalledPlugin('Supabase', URI.file('/workspace/.copilot/plugins/supabase'), true), -]; - -function makeMarketplacePlugin(name: string, description: string, repo: string): IMarketplacePlugin { - return { - name, - description, - version: '1.0.0', - source: repo, - sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: `example/${repo}` }, - marketplace: 'copilot', - marketplaceReference: { rawValue: `example/${repo}`, displayLabel: repo, cloneUrl: `https://github.com/example/${repo}.git`, canonicalId: `github:example/${repo}`, cacheSegments: ['example', repo], kind: MarketplaceReferenceKind.GitHubShorthand }, - marketplaceType: MarketplaceType.Copilot, - }; -} - -const marketplacePlugins: IMarketplacePlugin[] = [ - makeMarketplacePlugin('Linear', 'Issue tracking and project management integration', 'linear-plugin'), - makeMarketplacePlugin('Sentry', 'Error monitoring and performance tracing', 'sentry-plugin'), - makeMarketplacePlugin('Datadog', 'Observability and monitoring dashboards', 'datadog-plugin'), - makeMarketplacePlugin('Notion', 'Knowledge base and documentation management', 'notion-plugin'), - makeMarketplacePlugin('Figma', 'Design system inspection and asset export', 'figma-plugin'), - makeMarketplacePlugin('Stripe', 'Payment processing and billing management', 'stripe-plugin'), - makeMarketplacePlugin('Twilio', 'Communication APIs for SMS and voice', 'twilio-plugin'), - makeMarketplacePlugin('Auth0', 'Identity and access management', 'auth0-plugin'), - makeMarketplacePlugin('Algolia', 'Search and discovery API integration', 'algolia-plugin'), - makeMarketplacePlugin('LaunchDarkly', 'Feature flag management and experimentation', 'launchdarkly-plugin'), - makeMarketplacePlugin('PlanetScale', 'Serverless MySQL database management', 'planetscale-plugin'), - makeMarketplacePlugin('Vercel', 'Deployment and preview environments', 'vercel-plugin'), -]; - -async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise { - const width = 650; - const height = 500; - ctx.container.style.width = `${width}px`; - ctx.container.style.height = `${height}px`; - - // Some marketplace plugins match installed plugins by URI so the renderer - // shows them as "Installed" (exercises the installed-state check from #7379). - const browseInstalledPlugins = [ - makeInstalledPlugin('Linear', URI.file('/home/dev/.vscode/agent-plugins/example/linear-plugin'), true), - makeInstalledPlugin('Sentry', URI.file('/home/dev/.vscode/agent-plugins/example/sentry-plugin'), true), - makeInstalledPlugin('Datadog', URI.file('/home/dev/.vscode/agent-plugins/example/datadog-plugin'), false), - ]; - - // Map plugin source descriptors to install URIs, matching installed URIs above - const pluginInstallUris = new Map([ - ['example/linear-plugin', URI.file('/home/dev/.vscode/agent-plugins/example/linear-plugin')], - ['example/sentry-plugin', URI.file('/home/dev/.vscode/agent-plugins/example/sentry-plugin')], - ['example/datadog-plugin', URI.file('/home/dev/.vscode/agent-plugins/example/datadog-plugin')], - ]); - - const instantiationService = createEditorServices(ctx.disposableStore, { - colorTheme: ctx.theme, - additionalServices: (reg) => { - registerWorkbenchServices(reg); - reg.define(IListService, ListService); - reg.defineInstance(ICustomizationHarnessService, new class extends mock() { - override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); - override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } - override registerExternalHarness() { return { dispose() { } }; } - }()); - reg.defineInstance(IAgentPluginService, new class extends mock() { - override readonly plugins = constObservable(browseInstalledPlugins as readonly IAgentPlugin[]); - override readonly enablementModel = undefined!; - }()); - reg.defineInstance(IPluginMarketplaceService, new class extends mock() { - override readonly installedPlugins = constObservable([]); - override readonly onDidChangeMarketplaces = Event.None; - override async fetchMarketplacePlugins() { return marketplacePlugins; } - }()); - reg.defineInstance(IPluginInstallService, new class extends mock() { - override getPluginInstallUri(plugin: IMarketplacePlugin) { - const repo = plugin.sourceDescriptor.kind === PluginSourceKind.GitHub ? plugin.sourceDescriptor.repo : undefined; - return repo ? (pluginInstallUris.get(repo) ?? URI.file('/dev/null')) : URI.file('/dev/null'); - } - }()); - }, - }); - - const widget = ctx.disposableStore.add( - instantiationService.createInstance(PluginListWidget) - ); - ctx.container.appendChild(widget.element); - widget.layout(height, width); - - // Click the Browse Marketplace button to enter browse mode - const browseButton = widget.element.querySelector('.list-add-button') as HTMLElement; - browseButton?.click(); - - // Wait for the marketplace query to resolve, then wait for scrollbar fade transition - // (visible → invisible takes ~2s after programmatic scroll/list populate) - await new Promise(resolve => setTimeout(resolve, 100)); - // Blur the search input to prevent cursor blink instability in screenshots - (widget.element.querySelector('input') as HTMLElement)?.blur(); - // Force-hide scrollbars to avoid fade-transition instability - for (const scrollbar of widget.element.querySelectorAll('.scrollbar')) { - scrollbar.style.visibility = 'hidden'; - } - await new Promise(resolve => setTimeout(resolve, 200)); -} - -// ============================================================================ -// Fixtures -// ============================================================================ - -export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { - - // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, - // Generate buttons, AGENTS.md shortcut, all storage groups - LocalHarness: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), - }), - - // Full editor with Copilot CLI harness — no prompts section, CLI-specific - // root files and instruction filtering under .github/.copilot paths. - CliHarness: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.CLI }), - }), - - // Full editor with Claude harness — Prompts+Plugins hidden, Agents visible, - // "Add CLAUDE.md" button, "New Rule" dropdown, instruction filtering, bridged MCP badge - ClaudeHarness: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { harness: CustomizationHarness.Claude }), - }), - - // Sessions-window variant of the full editor with workspace override UX - // and sessions section ordering. - Sessions: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.CLI, - isSessionsWindow: true, - availableHarnesses: [ - createCliHarnessDescriptor(getCliUserRoots(userHome), [BUILTIN_STORAGE]), - ], - managementSections: [ - AICustomizationManagementSection.Agents, - AICustomizationManagementSection.Skills, - AICustomizationManagementSection.Instructions, - AICustomizationManagementSection.Prompts, - AICustomizationManagementSection.Hooks, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, - ], - }), - }), - - // Sessions Skills tab showing UI Integration badges on built-in skills - SessionsSkillsTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.CLI, - isSessionsWindow: true, - selectedSection: AICustomizationManagementSection.Skills, - availableHarnesses: [ - createCliHarnessDescriptor(getCliUserRoots(userHome), [BUILTIN_STORAGE]), - ], - managementSections: [ - AICustomizationManagementSection.Agents, - AICustomizationManagementSection.Skills, - AICustomizationManagementSection.Instructions, - AICustomizationManagementSection.Prompts, - AICustomizationManagementSection.Hooks, - AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Plugins, - ], - skillUIIntegrations: new Map([ - ['act-on-feedback', 'Used by the Submit Feedback button in the Changes toolbar'], - ['generate-run-commands', 'Used by the Run button in the title bar'], - ]), - }), - }), - - // MCP Servers tab with many servers to verify scrollable list layout - McpServersTab: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.McpServers, - }), - }), - - // Agents tab — workspace and user agents, scrollable - AgentsTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Agents, - }), - }), - - // Skills tab — workspace and user skills, scrollable - SkillsTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Skills, - }), - }), - - // Instructions tab — many instructions with applyTo patterns, scrollable - InstructionsTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Instructions, - }), - }), - - // Hooks tab — workspace and user hooks, scrollable - HooksTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Hooks, - }), - }), - - // Prompts tab — workspace and user prompts, scrollable - PromptsTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Prompts, - }), - }), - - // Plugins tab - PluginsTab: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Plugins, - }), - }), - - // MCP browse/marketplace mode — standalone widget with gallery results, scrollable - // Verifies fix for https://github.com/microsoft/vscode/issues/304139 - McpBrowseMode: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: renderMcpBrowseMode, - }), - - // Plugin browse/marketplace mode — standalone widget with marketplace results, scrollable - PluginBrowseMode: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: renderPluginBrowseMode, - }), - - // Scrolled-to-bottom variants — verify last items are fully visible above footer - PromptsTabScrolled: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Prompts, - scrollToBottom: true, - }), - }), - - McpServersTabScrolled: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.McpServers, - scrollToBottom: true, - }), - }), - - PluginsTabScrolled: defineComponentFixture({ - labels: { kind: 'screenshot' }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Plugins, - scrollToBottom: true, - }), - }), - - // Narrow viewport — catches badge clipping and layout overflow at small sizes - McpServersTabNarrow: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.McpServers, - width: 550, - height: 400, - }), - }), - - AgentsTabNarrow: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, - render: ctx => renderEditor(ctx, { - harness: CustomizationHarness.VSCode, - selectedSection: AICustomizationManagementSection.Agents, - width: 550, - height: 400, - }), - }), -}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts index 0c4a370522861..2b645d71aff7f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts @@ -13,7 +13,8 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; + +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ISharedWebContentExtractorService } from '../../../../../platform/webContentExtractor/common/webContentExtractor.js'; import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; @@ -106,8 +107,6 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: additionalServices: (reg) => { reg.define(IMenuService, FixtureMenuService); registerWorkbenchServices(reg); - // eslint-disable-next-line local/code-no-dangerous-type-assertions - reg.defineInstance(ITextModelService, new class extends mock() { override async createModelReference() { return { object: { textEditorModel: null }, dispose() { } } as unknown as Awaited>; } }()); reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); reg.defineInstance(ILanguageModelsService, new class extends mock() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } }()); @@ -141,6 +140,7 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: override readonly repositoryCount = 0; }()); reg.defineInstance(IActionWidgetService, new class extends mock() { override show() { } override hide() { } override get isVisible() { return false; } }()); + reg.defineInstance(IFileDialogService, new class extends mock() { }()); reg.defineInstance(IProductService, new class extends mock() { }()); reg.defineInstance(IUpdateService, new class extends mock() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }()); reg.defineInstance(IUriIdentityService, new class extends mock() { }()); @@ -201,34 +201,27 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: listBackground: 'var(--vscode-editor-background)', }; - try { - const inputPart = disposableStore.add(instantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, options, styles, false)); - const mockWidget = new class extends mock() { - override readonly onDidChangeViewModel = new Emitter().event; - override readonly viewModel = undefined; - override readonly contribs = []; - override readonly location = ChatAgentLocation.Chat; - override readonly viewContext = {}; - }(); + const inputPart = disposableStore.add(instantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, options, styles, false)); + const mockWidget = new class extends mock() { + override readonly onDidChangeViewModel = new Emitter().event; + override readonly viewModel = undefined; + override readonly contribs = []; + override readonly location = ChatAgentLocation.Chat; + override readonly viewContext = {}; + }(); - inputPart.render(session, '', mockWidget); - inputPart.layout(500); - await new Promise(r => setTimeout(r, 100)); - inputPart.layout(500); - inputPart.renderArtifactsWidget(URI.parse('chat-session:test-session')); - await inputPart.renderChatTodoListWidget(URI.parse('chat-session:test-session')); - await new Promise(r => setTimeout(r, 50)); + inputPart.render(session, '', mockWidget); + inputPart.layout(500); + await new Promise(r => setTimeout(r, 100)); + inputPart.layout(500); + inputPart.renderArtifactsWidget(URI.parse('chat-session:test-session')); + await inputPart.renderChatTodoListWidget(URI.parse('chat-session:test-session')); + await new Promise(r => setTimeout(r, 50)); - if (editingSession) { - inputPart.renderChatEditingSessionState(editingSession); - await new Promise(r => setTimeout(r, 50)); - inputPart.layout(500); - } - } catch (e) { - const err = document.createElement('pre'); - err.style.cssText = 'color:red;font-size:11px;white-space:pre-wrap'; - err.textContent = `Render error: ${e instanceof Error ? e.message : String(e)}`; - session.appendChild(err); + if (editingSession) { + inputPart.renderChatEditingSessionState(editingSession); + await new Promise(r => setTimeout(r, 50)); + inputPart.layout(500); } } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts new file mode 100644 index 0000000000000..60f1d288d9eea --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Dimension } from '../../../../../base/browser/dom.js'; +import { Event, ValueWithChangeEvent } from '../../../../../base/common/event.js'; +import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; +import { IResourceLabel as IMultiDiffResourceLabel, IWorkbenchUIElementFactory } from '../../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; +import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; +import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; +import { TestDiffProviderFactoryService } from '../../../../../editor/test/browser/diff/testDiffProviderFactoryService.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IEditorProgressService } from '../../../../../platform/progress/common/progress.js'; +import { IWorkspaceContextService, IWorkspace } from '../../../../../platform/workspace/common/workspace.js'; +import { ResourceLabel } from '../../../../browser/labels.js'; +import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; +import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +class FixtureWorkbenchUIElementFactory implements IWorkbenchUIElementFactory { + constructor( + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + createResourceLabel(element: HTMLElement): IMultiDiffResourceLabel { + const label = this._instantiationService.createInstance(ResourceLabel, element, {}); + return { + setUri(uri, options = {}) { + if (!uri) { + label.element.clear(); + } else { + label.element.setFile(uri, { strikethrough: options.strikethrough }); + } + }, + dispose() { + label.dispose(); + } + }; + } +} + +const ORIGINAL_CODE_1 = `function greet(name: string): string { + return 'Hello, ' + name; +} + +function main() { + console.log(greet('World')); +}`; + +const MODIFIED_CODE_1 = `function greet(name: string, greeting = 'Hello'): string { + return \`\${greeting}, \${name}!\`; +} + +function farewell(name: string): string { + return \`Goodbye, \${name}!\`; +} + +function main() { + console.log(greet('World')); + console.log(farewell('World')); +}`; + +const ORIGINAL_CODE_2 = `export interface Config { + host: string; + port: number; +} + +export const defaultConfig: Config = { + host: 'localhost', + port: 3000, +};`; + +const MODIFIED_CODE_2 = `export interface Config { + host: string; + port: number; + secure: boolean; + timeout: number; +} + +export const defaultConfig: Config = { + host: 'localhost', + port: 8080, + secure: true, + timeout: 30000, +};`; + +const ORIGINAL_CODE_3 = `import { Config } from './config'; + +export function createServer(config: Config) { + return { config }; +}`; + +const MODIFIED_CODE_3 = `import { Config } from './config'; + +export function createServer(config: Config) { + const { host, port, secure } = config; + const protocol = secure ? 'https' : 'http'; + console.log(\`Starting server at \${protocol}://\${host}:\${port}\`); + return { config, url: \`\${protocol}://\${host}:\${port}\` }; +}`; + +function renderMultiDiffEditor({ container, disposableStore, theme }: ComponentFixtureContext): void { + container.style.width = '800px'; + container.style.height = '600px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: (reg) => { + reg.define(IDiffProviderFactoryService, TestDiffProviderFactoryService); + reg.definePartialInstance(IEditorProgressService, { + show: () => ({ total: () => { }, worked: () => { }, done: () => { } }), + }); + reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); + reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }()); + reg.definePartialInstance(INotebookDocumentService, { getNotebook: () => undefined }); + registerWorkbenchServices(reg); + }, + }); + + const uiFactory = instantiationService.createInstance(FixtureWorkbenchUIElementFactory); + + const widget = disposableStore.add(instantiationService.createInstance( + MultiDiffEditorWidget, + container, + uiFactory, + )); + + // Text models must be disposed after the widget releases its references. + // DisposableStore disposes in insertion order, so we add a cleanup disposable + // after the widget that first clears the view model, then disposes text models. + const textModels = new DisposableStore(); + disposableStore.add(toDisposable(() => { + widget.setViewModel(undefined); + textModels.dispose(); + })); + + const original1 = textModels.add(createTextModel(instantiationService, ORIGINAL_CODE_1, URI.parse('inmemory://original/greet.ts'), 'typescript')); + const modified1 = textModels.add(createTextModel(instantiationService, MODIFIED_CODE_1, URI.parse('inmemory://modified/greet.ts'), 'typescript')); + + const original2 = textModels.add(createTextModel(instantiationService, ORIGINAL_CODE_2, URI.parse('inmemory://original/config.ts'), 'typescript')); + const modified2 = textModels.add(createTextModel(instantiationService, MODIFIED_CODE_2, URI.parse('inmemory://modified/config.ts'), 'typescript')); + + const original3 = textModels.add(createTextModel(instantiationService, ORIGINAL_CODE_3, URI.parse('inmemory://original/server.ts'), 'typescript')); + const modified3 = textModels.add(createTextModel(instantiationService, MODIFIED_CODE_3, URI.parse('inmemory://modified/server.ts'), 'typescript')); + + const documents: RefCounted[] = [ + RefCounted.createOfNonDisposable({ original: original1, modified: modified1 }, { dispose() { } }), + RefCounted.createOfNonDisposable({ original: original2, modified: modified2 }, { dispose() { } }), + RefCounted.createOfNonDisposable({ original: original3, modified: modified3 }, { dispose() { } }), + ]; + + const model: IMultiDiffEditorModel = { + documents: ValueWithChangeEvent.const(documents), + }; + + const viewModel = widget.createViewModel(model); + widget.setViewModel(viewModel); + + widget.layout(new Dimension(800, 600)); +} + +export default defineThemedFixtureGroup({ path: 'editor/' }, { + MultiDiffEditor: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderMultiDiffEditor(context), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index d81616cb257df..b43edc36e97a3 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -98,10 +98,11 @@ import { constObservable } from '../../../../base/common/observable.js'; // Editor import { ITextModel } from '../../../../editor/common/model.js'; - +import './fixtures.css'; // Import color registrations to ensure colors are available -import { isThenable } from '../../../../base/common/async.js'; +import { IdleDeadline, installFakeRunWhenIdle } from '../../../../base/common/async.js'; +import { AsyncSchedulerProcessor, TimeTravelScheduler } from '../../../../base/test/common/timeTravelScheduler.js'; import '../../../../platform/theme/common/colors/baseColors.js'; import '../../../../platform/theme/common/colors/editorColors.js'; import '../../../../platform/theme/common/colors/listColors.js'; @@ -291,7 +292,7 @@ function installGlobalStyles(): void { export function setupTheme(container: HTMLElement, theme: ColorThemeData): void { installGlobalStyles(); - container.classList.add('monaco-workbench', getPlatformClass(), ...theme.classNames); + container.classList.add('monaco-workbench', getPlatformClass(), 'disable-animations', ...theme.classNames); } function getPlatformClass(): string { @@ -449,6 +450,8 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre _serviceBrand: undefined, registerTextModelContentProvider: () => ({ dispose: () => { } }), canHandleResource: () => false, + // eslint-disable-next-line local/code-no-any-casts, @typescript-eslint/no-explicit-any + createModelReference: async () => ({ object: { textEditorModel: null }, dispose() { } } as any), }); defineInstance(IAgentFeedbackService, { @@ -479,6 +482,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre definePartialInstance(ISessionsManagementService, { _serviceBrand: undefined, + activeSession: constObservable(undefined), getSession: () => undefined, getSessions: () => [], }); @@ -624,12 +628,53 @@ export function defineComponentFixture(options: ComponentFixtureOptions): Themed isolation: 'none', displayMode: { type: 'component' }, background: theme === darkTheme ? 'dark' : 'light', - render: (container: HTMLElement) => { + render: async (container: HTMLElement) => { const disposableStore = new DisposableStore(); - setupTheme(container, theme); - // Start render (may be async) - component-explorer will wait 2 rAF after this returns - const result = options.render({ container, disposableStore, theme }); - return isThenable(result) ? result.then(() => disposableStore) : disposableStore; + + const schedulerStore = disposableStore.add(new DisposableStore()); + const scheduler = new TimeTravelScheduler(Date.now()); + const p = schedulerStore.add(new AsyncSchedulerProcessor(scheduler, { + maxTaskCount: 100, + })); + + async function actualRender() { + + setupTheme(container, theme); + + // Temporarily disable TimeTravelScheduler, as this needs a component explorer update + // schedulerStore.add(scheduler.installGlobally()); + disposableStore.add(installFakeRunWhenIdle((_targetWindow, callback, _timeout?) => { + return scheduler.schedule({ + time: scheduler.now, + run: () => { + const deadline: IdleDeadline = { + didTimeout: true, + timeRemaining: () => 50, + }; + callback(deadline); + }, + source: { + toString() { return 'runWhenIdle'; }, + stackTrace: undefined, + }, + }); + })); + + const result = options.render({ container, disposableStore, theme }); + + const p2 = p.waitFor(1000); + + await Promise.all([ + result instanceof Promise ? result : Promise.resolve(), + p2, + ]); + } + + await actualRender(); + + schedulerStore.dispose(); + + return disposableStore; }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtures.css b/src/vs/workbench/test/browser/componentFixtures/fixtures.css new file mode 100644 index 0000000000000..2e300509ce7db --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/fixtures.css @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.disable-animations { + * { + transition: none !important; + } +} diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts index 48f325e564ebb..7dc55c74d5a92 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts @@ -48,7 +48,10 @@ function createMockSession(overrides: Partial & { label: string; }; override isArchived(): boolean { return overrides.isArchived?.() ?? false; } override setArchived(): void { } + override isPinned(): boolean { return overrides.isPinned?.() ?? false; } + override setPinned(): void { } override isRead(): boolean { return overrides.isRead?.() ?? true; } + override isMarkedUnread(): boolean { return false; } override setRead(): void { } }(); } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts index cc0cdc2a99da8..7a4c835f78e8a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts @@ -51,6 +51,7 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a override readonly onDidChangeCustomAgents = Event.None; override readonly onDidChangeSlashCommands = Event.None; override readonly onDidChangeSkills = Event.None; + override readonly onDidChangeInstructions = Event.None; override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } override async listPromptFiles(type: PromptsType) { if (type === PromptsType.instructions) { @@ -60,6 +61,15 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a } override async listAgentInstructions() { return agentInstructionFiles; } override async getCustomAgents() { return []; } + override async getInstructionFiles() { + return instructionFiles.map(f => ({ + uri: f.promptPath.uri, + name: f.name ?? '', + description: f.description, + storage: f.promptPath.storage, + pattern: f.applyTo, + })); + } override async parseNew(uri: URI): Promise { const file = instructionFiles.find(f => isEqual(f.promptPath.uri, uri)); const headerLines = []; diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index 01ffe081f34fe..de97db1db6624 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -134,6 +134,16 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IAge return new ParsedPromptFile(uri, header as never); } override async getSourceFolders() { return [] as never[]; } + override async getInstructionFiles() { + return files.filter(f => f.type === PromptsType.instructions).map(f => ({ + uri: f.uri, + name: f.name ?? '', + description: f.description, + storage: f.storage, + pattern: f.applyTo, + extension: toExtensionInfo(f) as never, + })); + } override async findAgentSkills(): Promise { return files.filter(f => f.type === PromptsType.skill).map(f => ({ uri: f.uri, @@ -717,13 +727,33 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise([ + ['example/linear-plugin', URI.file('/home/dev/.vscode/agent-plugins/example/linear-plugin')], + ['example/sentry-plugin', URI.file('/home/dev/.vscode/agent-plugins/example/sentry-plugin')], + ['example/datadog-plugin', URI.file('/home/dev/.vscode/agent-plugins/example/datadog-plugin')], + ]); + const instantiationService = createEditorServices(ctx.disposableStore, { colorTheme: ctx.theme, additionalServices: (reg) => { registerWorkbenchServices(reg); reg.define(IListService, ListService); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly activeHarness = observableValue('activeHarness', CustomizationHarness.VSCode); + override getActiveDescriptor() { return createVSCodeHarnessDescriptor([PromptsStorage.extension, BUILTIN_STORAGE]); } + override registerExternalHarness() { return { dispose() { } }; } + }()); reg.defineInstance(IAgentPluginService, new class extends mock() { - override readonly plugins = constObservable([] as readonly IAgentPlugin[]); + override readonly plugins = constObservable(browseInstalledPlugins as readonly IAgentPlugin[]); override readonly enablementModel = undefined!; }()); reg.defineInstance(IPluginMarketplaceService, new class extends mock() { @@ -732,7 +762,10 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise() { - override getPluginInstallUri() { return URI.file('/dev/null'); } + override getPluginInstallUri(plugin: IMarketplacePlugin) { + const repo = plugin.sourceDescriptor.kind === PluginSourceKind.GitHub ? plugin.sourceDescriptor.repo : undefined; + return repo ? (pluginInstallUris.get(repo) ?? URI.file('/dev/null')) : URI.file('/dev/null'); + } }()); }, }); @@ -747,8 +780,16 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise setTimeout(resolve, 50)); + // Wait for the marketplace query to resolve, then wait for scrollbar fade transition + // (visible → invisible takes ~2s after programmatic scroll/list populate) + await new Promise(resolve => setTimeout(resolve, 100)); + // Blur the search input to prevent cursor blink instability in screenshots + (widget.element.querySelector('input') as HTMLElement)?.blur(); + // Force-hide scrollbars to avoid fade-transition instability + for (const scrollbar of widget.element.querySelectorAll('.scrollbar')) { + scrollbar.style.visibility = 'hidden'; + } + await new Promise(resolve => setTimeout(resolve, 200)); } // ============================================================================ @@ -760,7 +801,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Full editor with Local (VS Code) harness — all sections visible, harness dropdown, // Generate buttons, AGENTS.md shortcut, all storage groups LocalHarness: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, + labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode }), }), @@ -828,7 +869,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // MCP Servers tab with many servers to verify scrollable list layout McpServersTab: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, + labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode, selectedSection: AICustomizationManagementSection.McpServers, @@ -932,7 +973,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { // Narrow viewport — catches badge clipping and layout overflow at small sizes McpServersTabNarrow: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, + labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode, selectedSection: AICustomizationManagementSection.McpServers, @@ -942,7 +983,7 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { }), AgentsTabNarrow: defineComponentFixture({ - labels: { kind: 'screenshot', blocksCi: true }, + labels: { kind: 'screenshot' }, render: ctx => renderEditor(ctx, { harness: CustomizationHarness.VSCode, selectedSection: AICustomizationManagementSection.Agents, diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 82c4f026ba080..fe2390142670f 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -583,11 +583,11 @@ export class TestMenuService implements IMenuService { } getMenuActions(id: MenuId, contextKeyService: IContextKeyService, options?: IMenuActionOptions): [string, Array][] { - throw new Error('Method not implemented.'); + return []; } getMenuContexts(id: MenuId): ReadonlySet { - throw new Error('Method not implemented.'); + return new Set(); } resetHiddenStates(): void { diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 38c99529cebdb..6e9a425b8bca3 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -9,7 +9,12 @@ declare module 'vscode' { // #region Resource Classes /** - * Represents a chat-related resource, such as a custom agent, instructions, prompt file, or skill. + * Indicates where a chat resource was loaded from. + */ + export type ChatResourceSource = 'local' | 'user' | 'extension' | 'plugin'; + + /** + * Represents a chat-related resource, such as a custom agent, instructions, prompt file, skill, or slash command. */ export interface ChatResource { /** @@ -18,6 +23,189 @@ declare module 'vscode' { readonly uri: Uri; } + /** + * Represents a custom chat agent resource. + */ + export interface ChatCustomAgent { + /** + * Uri to the custom agent. This is typically a `.agent.md` file. + */ + readonly uri: Uri; + + /** + * Display name of the custom agent. + */ + readonly name: string; + + /** + * Optional description of the custom agent. + */ + readonly description?: string; + + /** + * Where the custom agent was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * Optional hint that describes what arguments the custom agent accepts. + */ + readonly argumentHint?: string; + + /** + * Whether this custom agent should be shown to users as invocable. + */ + readonly userInvocable: boolean; + + /** + * Whether this custom agent should be excluded from model invocation. + */ + readonly disableModelInvocation: boolean; + } + + /** + * Represents an instruction file resource. + */ + export interface ChatInstruction { + /** + * Uri to the instruction. + */ + readonly uri: Uri; + + /** + * Display name of the instruction. + */ + readonly name: string; + + /** + * Optional description of the instruction. + */ + readonly description?: string; + + /** + * Where the instruction was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * The optional apply pattern used to scope the instruction. + */ + readonly pattern?: string; + } + + /** + * Represents a skill resource. + */ + export interface ChatSkill { + /** + * Uri to the chat resource. This is typically a `.agent.md`, `.instructions.md`, `.prompt.md`, or `SKILL.md` file. + */ + readonly uri: Uri; + + /** + * Display name of the skill. + */ + readonly name: string; + + /** + * Optional description of the skill. + */ + readonly description?: string; + + /** + * Where the skill was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * Whether this skill should be shown to users as invocable. + */ + readonly userInvocable?: boolean; + } + + /** + * Represents a slash command resource. + */ + export interface ChatSlashCommand { + /** + * Uri to the chat resource. + */ + readonly uri: Uri; + + /** + * Display name of the chat resource. + */ + readonly name: string; + + /** + * Optional description of the chat resource. + */ + readonly description?: string; + + /** + * Where the chat resource was loaded from. + */ + readonly source: ChatResourceSource; + + /** + * The contributing extension identifier when {@link source} is `extension`. + */ + readonly extensionId?: string; + + /** + * The contributing plugin URI when {@link source} is `plugin`. + */ + readonly pluginUri?: Uri; + + /** + * Optional hint that describes what arguments the slash command accepts. + */ + readonly argumentHint?: string; + + /** + * Whether this slash command should be shown to users as invocable. + */ + readonly userInvocable?: boolean; + } + + export interface ChatHook { + readonly uri: Uri; + } + + export interface ChatPlugin { + readonly uri: Uri; + } + // #endregion // #region Providers @@ -133,8 +321,16 @@ declare module 'vscode' { /** * The list of currently available custom agents. These are `.agent.md` files * from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getCustomAgents provideCustomAgents} instead, which queries the current list of custom agents on demand. This property may become out of sync with the actual available custom agents. + */ + export const customAgents: readonly ChatCustomAgent[]; + + /** + * Provide the list of currently available custom agents. These are `.agent.md` files + * from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. */ - export const customAgents: readonly ChatResource[]; + export function getCustomAgents(token: CancellationToken): Thenable; /** * An event that fires when the list of {@link instructions instructions} changes. @@ -144,8 +340,16 @@ declare module 'vscode' { /** * The list of currently available instructions. These are `.instructions.md` files * from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getInstructions getInstructions} instead, which queries the current list of instructions on demand. This property may become out of sync with the actual available instructions. */ - export const instructions: readonly ChatResource[]; + export const instructions: readonly ChatInstruction[]; + + /** + * Provide the list of currently available instructions. These are `.instructions.md` files + * from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getInstructions(token: CancellationToken): Thenable; /** * An event that fires when the list of {@link skills skills} changes. @@ -155,8 +359,35 @@ declare module 'vscode' { /** * The list of currently available skills. These are `SKILL.md` files * from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getSkills getSkills} instead, which queries the current list of skills on demand. This property may become out of sync with the actual available skills. + */ + export const skills: readonly ChatSkill[]; + + /** + * Provide the list of currently available skills. These are `SKILL.md` files + * from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getSkills(token: CancellationToken): Thenable; + + /** + * An event that fires when the list of {@link slashCommands slash commands} changes. + */ + export const onDidChangeSlashCommands: Event; + + /** + * The list of currently available slash commands. These are `.prompt.md` files and + * user-invocable `SKILL.md` files from all sources (workspace, user, and extension-provided). + * @deprecated Use {@link getSlashCommands getSlashCommands} instead, which queries the current list of slash commands on demand. This property may become out of sync with the actual available slash commands. */ - export const skills: readonly ChatResource[]; + export const slashCommands: readonly ChatSlashCommand[]; + + /** + * Provide the list of currently available slash commands. These are `.prompt.md` files and + * user-invocable `SKILL.md` files from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getSlashCommands(token: CancellationToken): Thenable; /** * An event that fires when the list of {@link hooks hooks} changes. @@ -167,9 +398,16 @@ declare module 'vscode' { * The list of currently available hook configuration files. * These are JSON files that define lifecycle hooks from all sources * (workspace, user, and extension-provided). + * @deprecated Use {@link getHooks getHooks} instead, which queries the current list of hook configuration files on demand. This property may become out of sync with the actual available hook configuration files. */ export const hooks: readonly ChatResource[]; + /** + * Provide the list of currently available hook configuration files. These are JSON files that define lifecycle hooks from all sources (workspace, user, and extension-provided). + * @param token A cancellation token. + */ + export function getHooks(token: CancellationToken): Thenable; + /** * An event that fires when the list of {@link plugins plugins} changes. */ @@ -177,9 +415,16 @@ declare module 'vscode' { /** * The list of currently installed agent plugins. + * @deprecated Use {@link getPlugins getPlugins} instead, which queries the current list of installed agent plugins on demand. This property may become out of sync with the actual installed agent plugins. */ export const plugins: readonly ChatResource[]; + /** + * Provide the list of currently installed agent plugins. + * @param token A cancellation token. + */ + export function getPlugins(token: CancellationToken): Thenable; + /** * Register a provider for custom agents. * @param provider The custom agent provider. diff --git a/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts index 04a50dd823cf7..855056f181e2c 100644 --- a/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts +++ b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts @@ -77,9 +77,12 @@ declare module 'vscode' { * when the last gateway is disposed. The gateway dynamically tracks server * additions and removals via {@link McpGateway.onDidChangeServers}. * + * @param chatSessionResource Optional chat session resource URI to associate with this + * gateway. When provided, MCP tool calls made through this gateway will be associated + * with the chat session, enabling inline elicitation UI in the chat response. * @returns A promise that resolves to an {@link McpGateway} if successful, * or `undefined` if no Node process is available (e.g., in serverless web environments). */ - export function startMcpGateway(): Thenable; + export function startMcpGateway(chatSessionResource?: Uri): Thenable; } } diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 8145769040fbd..c531fa5c8df48 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -1,29 +1,5 @@ -#### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5dde8b97d2124e8277c145e6fe118fd99dc3d82715311f968afae09d6682856a) - -#### chat/aiCustomizations/aiCustomizationManagementEditor/AgentsTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5dde8b97d2124e8277c145e6fe118fd99dc3d82715311f968afae09d6682856a) - -#### chat/aiCustomizations/aiCustomizationManagementEditor/LocalHarness/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/89c6f26c1eae112fda0cc2a6acd5fde5960779ff9000d84049674e5cb4991073) - -#### chat/aiCustomizations/aiCustomizationManagementEditor/LocalHarness/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/89c6f26c1eae112fda0cc2a6acd5fde5960779ff9000d84049674e5cb4991073) - -#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/89c6f26c1eae112fda0cc2a6acd5fde5960779ff9000d84049674e5cb4991073) - -#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTab/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/89c6f26c1eae112fda0cc2a6acd5fde5960779ff9000d84049674e5cb4991073) - -#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTabNarrow/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5dde8b97d2124e8277c145e6fe118fd99dc3d82715311f968afae09d6682856a) - -#### chat/aiCustomizations/aiCustomizationManagementEditor/McpServersTabNarrow/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/5dde8b97d2124e8277c145e6fe118fd99dc3d82715311f968afae09d6682856a) - #### editor/codeEditor/CodeEditor/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/fb6693f7373d126fda52629148610777edb8ae8fec5b9f3d14053d25b86cfd56) diff --git a/test/sanity/src/devTunnel.test.ts b/test/sanity/src/devTunnel.test.ts index 3c54432d6680c..98706bfb26a6f 100644 --- a/test/sanity/src/devTunnel.test.ts +++ b/test/sanity/src/devTunnel.test.ts @@ -66,6 +66,7 @@ export function setup(context: TestContext) { await testCliApp(entryPoint); }); + /** TODO: @dmitrivMS Fix flakiness and then reenable context.test('dev-tunnel-win32-x64', ['windows', 'x64', 'browser', 'github-account'], async () => { const dir = await context.downloadAndUnpack('cli-win32-x64'); context.validateAllAuthenticodeSignatures(dir); @@ -73,6 +74,7 @@ export function setup(context: TestContext) { const entryPoint = context.getCliEntryPoint(dir); await testCliApp(entryPoint); }); + */ async function testCliApp(entryPoint: string) { if (context.options.downloadOnly) {