diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index 3d0d41aa23068..3c8b4c947c48a 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -183,6 +183,50 @@ The file `src/vs/workbench/contrib/policyExport/test/node/extensionPolicyFixture | `vscode-website` (`gulpfile.policies.js`) | `policyData.jsonc` | Enterprise policy reference table at code.visualstudio.com/docs/enterprise/policies | | `vscode-docs` | Generated from website build | `docs/enterprise/policies.md` | +## GitHub Preview Features + +If your setting is a **GitHub Preview Feature** — meaning it's a Copilot/chat feature that organizations can disable via their GitHub account-level policy — you **must** add a `value` function that checks `policyData.chat_preview_features_enabled`. + +### When to add this flag + +Add the `chat_preview_features_enabled` check when **all** of these apply: + +- The setting controls a Copilot or chat feature (e.g., agent tools, hooks, MCP, auto-approve) +- The feature is in preview or experimental status (typically tagged `'preview'` or `'experimental'`) +- An organization admin should be able to disable it for all users in their org via GitHub account policy + +### How it works + +The `chat_preview_features_enabled` field on `IPolicyData` (defined in `src/vs/base/common/defaultAccount.ts`) is populated from the user's GitHub Copilot token entitlements. When an organization admin disables preview features, `chat_preview_features_enabled` is set to `false`. + +### Pattern + +Add a `value` function to the policy that returns a disabling value when `chat_preview_features_enabled === false`, and `undefined` otherwise (to fall through to the user's own setting): + +```typescript +policy: { + name: 'MyPreviewFeaturePolicy', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.xx', // Must match the first VS Code release that ships this policy. + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'my.setting.description', + value: nls.localize('my.setting.description', "Description of the setting."), + } + } +} +``` + +Key details: +- **Always compare with `=== false`**, not `!policyData.chat_preview_features_enabled` — the field is optional and `undefined` means "no policy data available", which should not disable the feature. +- **Return `undefined`** when the flag is not `false` so the account-level policy does not override the user's setting. +- **Return the disabling value** for the setting's type: `false` for booleans, a restrictive string/enum value for other types. + +### Real-world examples + +See `chat.tools.global.autoApprove` and `chat.useHooks` in `src/vs/workbench/contrib/chat/browser/chat.contribution.ts` for existing settings that use this pattern. + ## Examples Search the codebase for `policy:` to find all the examples of different policy configurations. diff --git a/.github/skills/chat-perf/SKILL.md b/.github/skills/chat-perf/SKILL.md new file mode 100644 index 0000000000000..872454cf9f717 --- /dev/null +++ b/.github/skills/chat-perf/SKILL.md @@ -0,0 +1,265 @@ +--- +name: chat-perf +description: Run chat perf benchmarks and memory leak checks against the local dev build or any published VS Code version. Use when investigating chat rendering regressions, validating perf-sensitive changes to chat UI, or checking for memory leaks in the chat response pipeline. +--- + +# Chat Performance Testing + +## When to use + +- Before/after modifying chat rendering code (`chatListRenderer.ts`, `chatInputPart.ts`, markdown rendering) +- When changing the streaming response pipeline or SSE processing +- When modifying disposable/lifecycle patterns in chat components +- To compare performance between two VS Code releases +- In CI to gate PRs that touch chat UI code + +## Quick start + +```bash +# Run perf regression test (compares local dev build vs VS Code 1.115.0): +npm run perf:chat -- --scenario text-only --runs 3 + +# Run all scenarios with no baseline (just measure): +npm run perf:chat -- --no-baseline --runs 3 + +# Compare two local builds (apples-to-apples): +npm run perf:chat -- --build /path/to/build-A --baseline-build /path/to/build-B --runs 5 + +# Build a local production package and compare against a release: +npm run perf:chat -- --production-build --baseline-build 1.115.0 --runs 5 + +# Run memory leak check (10 messages in one session): +npm run perf:chat-leak + +# Run leak check with more messages for accuracy: +npm run perf:chat-leak -- --messages 20 --verbose +``` + +## Perf regression test + +**Script:** `scripts/chat-simulation/test-chat-perf-regression.js` +**npm:** `npm run perf:chat` + +Launches VS Code via Playwright Electron, opens the chat panel, sends a message with a mock LLM response, and measures timing, layout, and rendering metrics. By default, downloads VS Code 1.115.0 as a baseline, benchmarks it, then benchmarks the local dev build and compares. + +### Key flags + +| Flag | Default | Description | +|---|---|---| +| `--runs ` | `5` | Runs per scenario. More = more stable. Use 5+ for CI. | +| `--scenario ` / `-s` | all | Scenario to test (repeatable). See `common/perf-scenarios.js`. | +| `--build ` / `-b` | local dev | Build to test. Accepts path or version (`1.110.0`, `insiders`, commit hash). | +| `--baseline ` | — | Compare against a previously saved baseline JSON file. | +| `--baseline-build ` | `1.115.0` | Version or local path to benchmark as baseline. | +| `--no-baseline` | — | Skip baseline comparison entirely. | +| `--save-baseline` | — | Save results as the new baseline (requires `--baseline `). | +| `--resume ` | — | Resume a previous run, adding more iterations to increase confidence. | +| `--threshold ` | `0.2` | Regression threshold (0.2 = flag if 20% slower). | +| `--production-build` | — | Build a local bundled package via `gulp vscode` for comparison against a release baseline. | +| `--no-cache` | — | Ignore cached baseline data, always run fresh. | +| `--force` | — | Skip build mode mismatch confirmation prompt. | +| `--ci` | — | CI mode: write Markdown summary to `ci-summary.md` (implies `--no-cache`). | +| `--setting ` | — | Set a VS Code setting override for all builds (repeatable). | +| `--test-setting ` | — | Set a VS Code setting override for the test build only. | +| `--baseline-setting ` | — | Set a VS Code setting override for the baseline build only. | +| `--verbose` | — | Print per-run details including response content. | + +### Comparing two remote builds + +```bash +# Compare 1.110.0 against 1.115.0 (no local build needed): +npm run perf:chat -- --build 1.110.0 --baseline-build 1.115.0 --runs 5 +``` + +### Comparing two local builds + +Both `--build` and `--baseline-build` accept local paths to VS Code executables. This enables apples-to-apples comparisons between any two builds: + +```bash +# Compare two dev builds (e.g. feature branch vs main): +npm run perf:chat -- \ + --build .build/electron/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \ + --baseline-build /path/to/other/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \ + --runs 5 + +# Compare two production builds: +npm run perf:chat -- \ + --build ../VSCode-darwin-arm64-feature/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \ + --baseline-build ../VSCode-darwin-arm64-main/Code\ -\ OSS.app/Contents/MacOS/Code\ -\ OSS \ + --runs 5 +``` + +Local path baselines are never cached (the build may change between runs). Version string baselines are cached for reuse. + +### Build modes and mismatch detection + +The tool classifies builds into three modes based on the executable path: + +| Mode | Source | Characteristics | +|---|---|---| +| `dev` | `.build/electron/` (local dev) | Unbundled sources, `VSCODE_DEV=1`, `NODE_ENV=development`. Higher memory and startup overhead. | +| `production` | `../VSCode--/` (from `gulp vscode`) | Bundled JS, no dev flags. Matches release characteristics but uses local source. | +| `release` | `.vscode-test/` (downloaded via `@vscode/test-electron`) | Official published build. | + +When test and baseline builds have different modes (e.g. dev vs release), the tool shows a warning and prompts for confirmation. Use `--force` or `--ci` to skip the prompt. + +Using `--production-build` builds a local bundled package via `gulp vscode` for fair comparison against a release baseline. This eliminates dev-mode overhead while still testing your local changes. + +```bash +# Production build vs release baseline (fair comparison): +npm run perf:chat -- --production-build --baseline-build 1.115.0 --runs 5 +``` + +### Settings overrides + +Use `--setting`, `--test-setting`, and `--baseline-setting` to inject VS Code settings into the launched instance. This is useful for A/B testing experimental features: + +```bash +# Enable a feature for the test build only: +npm run perf:chat -- --test-setting chat.experimental.incrementalRendering.enabled=true --runs 3 + +# Compare two builds with different settings: +npm run perf:chat -- \ + --baseline-build "../vscode2/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS" \ + --baseline-setting chat.experimental.incrementalRendering.enabled=true \ + --test-setting chat.experimental.incrementalRendering.enabled=false \ + --runs 3 + +# Set a value for both builds: +npm run perf:chat -- --setting chat.mcp.enabled=false --runs 3 +``` + +Precedence: `--test-setting` / `--baseline-setting` override `--setting` for the same key. Values are auto-parsed: `true`/`false` become booleans, numbers become numbers, everything else stays a string. + +### Resuming a run for more confidence + +When results exceed the threshold but aren't statistically significant, the tool prints a `--resume` hint. Use it to add more iterations to an existing run: + +```bash +# Initial run with 3 iterations — may be inconclusive: +npm run perf:chat -- --scenario text-only --runs 3 + +# Add 3 more runs to the same results file (both test + baseline): +npm run perf:chat -- --resume .chat-simulation-data/2026-04-14T02-15-14/results.json --runs 3 + +# Keep adding until confidence is reached: +npm run perf:chat -- --resume .chat-simulation-data/2026-04-14T02-15-14/results.json --runs 5 +``` + +`--resume` loads the previous `results.json` and its associated `baseline-*.json`, runs N more iterations for both builds, merges rawRuns, recomputes stats, and re-runs the comparison. The updated files are written back in-place. You can resume multiple times — samples accumulate. + +### Statistical significance + +Regression detection uses **Welch's t-test** to avoid false positives from noisy measurements. A metric is only flagged as `REGRESSION` when it both exceeds the threshold AND is statistically significant (p < 0.05). Otherwise it's reported as `(likely noise — p=X, not significant)`. + +With typical variance (cv ≈ 20%), you need: +- **n ≥ 5** per build to detect a 35% regression at 95% confidence +- **n ≥ 10** per build to detect a 20% regression reliably + +Confidence levels reported: `high` (p < 0.01), `medium` (p < 0.05), `low` (p < 0.1), `none`. + +### Exit codes + +- `0` — all metrics within threshold, or exceeding threshold but not statistically significant +- `1` — statistically significant regression detected, or all runs failed + +### Scenarios + +Scenarios are defined in `scripts/chat-simulation/common/perf-scenarios.js` and registered via `registerPerfScenarios()`. There are three categories: + +- **Content-only** — plain streaming responses (e.g. `text-only`, `large-codeblock`, `rapid-stream`) +- **Tool-call** — multi-turn scenarios with tool invocations (e.g. `tool-read-file`, `tool-edit-file`) +- **Multi-turn user** — multi-turn conversations with user follow-ups, thinking blocks (e.g. `thinking-response`, `multi-turn-user`, `long-conversation`) + +Run `npm run perf:chat -- --help` to see the full list of registered scenario IDs. + +### Metrics collected + +- **Timing:** time to first token, time to complete, time to render complete (includes typewriter animation) +- **Rendering:** layout count, layout duration (ms), style recalculation count, forced reflows, long tasks (>50ms), long animation frame count and duration +- **Memory:** heap before/after, heap delta post-GC (informational, noisy for single requests) +- **Extension host:** heap before/after/delta via CDP inspector + +### Regression triggers vs informational metrics + +Only these metrics trigger a regression failure (when they exceed the threshold with statistical significance): +- `timeToFirstToken`, `timeToComplete` — user-perceived latency +- `forcedReflowCount` — forced synchronous layouts are always bad +- `longTaskCount`, `longAnimationFrameCount` — main thread jank + +These are reported but **informational only** (won't fail CI): +- `layoutCount` — inflated by CSS animations; use `layoutDurationMs` instead +- `layoutDurationMs` — total layout time from trace (more meaningful than count) +- `recalcStyleCount` — inflated by CSS animations (compositor-driven, cheap) +- `timeToRenderComplete` — includes typewriter animation tail +- Memory/heap metrics — too noisy for single-request benchmarks + +### Statistics + +Results use **IQR-based outlier removal** and **median** (not mean) to handle startup jitter. The **coefficient of variation (cv)** is reported — under 15% is stable, over 15% gets a ⚠ warning. Baseline comparison uses **Welch's t-test** on raw run values to determine statistical significance before flagging regressions. Use 5+ runs to get stable results. + +## Memory leak check + +**Script:** `scripts/chat-simulation/test-chat-mem-leaks.js` +**npm:** `npm run perf:chat-leak` + +Launches one VS Code session, sends N messages sequentially, forces GC between each, and measures renderer heap and DOM node count. Uses **linear regression** on the samples to compute per-message growth rate, which is compared against a threshold. + +### Key flags + +| Flag | Default | Description | +|---|---|---| +| `--messages ` / `-n` | `10` | Number of messages to send. More = more accurate slope. | +| `--build ` / `-b` | local dev | Build to test. | +| `--threshold ` | `2` | Max per-message heap growth in MB. | +| `--setting ` | — | Set a VS Code setting override (repeatable). | +| `--verbose` | — | Print per-message heap/DOM counts. | + +### What it measures + +- **Heap growth slope** (MB/message) — linear regression over forced-GC heap samples. A leak shows as sustained positive slope. +- **DOM node growth** (nodes/message) — catches rendering leaks where elements aren't cleaned up. Healthy chat virtualizes old messages so node count plateaus. + +### Interpreting results + +- `0.3–1.0 MB/msg` — normal (V8 internal overhead, string interning) +- `>2.0 MB/msg` — likely leak, investigate retained objects +- DOM nodes stable after first message — normal (chat list virtualization working) +- DOM nodes growing linearly — rendering leak, check disposable cleanup + +## Architecture + +``` +scripts/chat-simulation/ +├── common/ +│ ├── mock-llm-server.js # Mock CAPI server matching @vscode/copilot-api URL structure +│ ├── perf-scenarios.js # Built-in scenario definitions (content, tool-call, multi-turn) +│ └── utils.js # Shared: paths, env setup, stats, launch helpers +├── config.jsonc # Default config (baseline version, runs, thresholds) +├── fixtures/ # TypeScript fixture files used by tool-call scenarios +├── test-chat-perf-regression.js +└── test-chat-mem-leaks.js +``` + +### Mock server + +The mock LLM server (`common/mock-llm-server.js`) implements the full CAPI URL structure from `@vscode/copilot-api`'s `DomainService`: + +- `GET /models` — returns model metadata +- `POST /models/session` — returns `AutoModeAPIResponse` with `available_models` and `session_token` +- `POST /models/session/intent` — model router +- `POST /chat/completions` — SSE streaming response matching the scenario +- Agent, session, telemetry, and token endpoints + +The copilot extension connects to this server via `IS_SCENARIO_AUTOMATION=1` mode with `overrideCapiUrl` and `overrideProxyUrl` settings. The `vscode-api-tests` extension must be disabled (`--disable-extension=vscode.vscode-api-tests`) because it contributes a duplicate `copilot` vendor that blocks the real extension's language model provider registration. + +### Adding a scenario + +1. Add a new entry to the appropriate object (`CONTENT_SCENARIOS`, `TOOL_CALL_SCENARIOS`, or `MULTI_TURN_SCENARIOS`) in `common/perf-scenarios.js` using the `ScenarioBuilder` API from `common/mock-llm-server.js` +2. The scenario is auto-registered by `registerPerfScenarios()` — no manual ID list to update +3. Run: `npm run perf:chat -- --scenario your-new-scenario --runs 1 --no-baseline --verbose` + +## Related skills + +- **heap-snapshot-analysis** — When a perf regression or leak check identifies high memory growth, use the heap-snapshot-analysis skill to dig deeper. It can parse `.heapsnapshot` files, compare before/after snapshots, group object deltas, and trace retainer paths to find what keeps disposed objects alive. The chat-perf leak check measures overall heap slope; heap-snapshot-analysis finds the specific objects responsible. +- **auto-perf-optimize** — For launching VS Code, driving a scenario, and capturing heap snapshots or CPU profiles automatically before doing low-level analysis. diff --git a/.github/workflows/chat-perf.yml b/.github/workflows/chat-perf.yml new file mode 100644 index 0000000000000..fc3e8aafd5e1e --- /dev/null +++ b/.github/workflows/chat-perf.yml @@ -0,0 +1,467 @@ +name: Chat Performance Comparison + +on: + workflow_dispatch: + inputs: + baseline_build: + description: "Baseline version or commit SHA (e.g. \"1.116.0\", \"insiders\", \"abc1234\"). Default: config.jsonc baselineBuild." + required: false + type: string + test_build: + description: "Branch, PR ref, commit SHA, or version to test (e.g. \"my-feature\", \"refs/pull/12345/head\", \"1.115.0\"). Default: current pipeline branch (probably main)." + required: false + type: string + runs: + description: "Runs per scenario" + required: false + type: number + default: 7 + scenarios: + description: "Comma-separated scenario list. Default: all registered scenarios." + required: false + type: string + default: "" + threshold: + description: "Regression threshold fraction (0.2 = 20%)" + required: false + type: number + default: 0.2 + skip_leak_check: + description: "Skip the memory leak check step" + required: false + type: boolean + default: false + test_settings: + description: 'JSON object of VS Code settings for the test build (e.g. {"chat.experimental.smoothStreaming.enabled": true})' + required: false + type: string + default: "" + baseline_settings: + description: 'JSON object of VS Code settings for the baseline build' + required: false + type: string + default: "" +permissions: + contents: read + +concurrency: + group: chat-perf-${{ github.run_id }} + cancel-in-progress: true + +env: + # Only set when explicitly provided; otherwise scripts read config.jsonc + BASELINE_BUILD_INPUT: ${{ inputs.baseline_build || '' }} + TEST_BUILD_INPUT: ${{ inputs.test_build || '' }} + PERF_RUNS: ${{ inputs.runs || '' }} + PERF_THRESHOLD: ${{ inputs.threshold || '' }} + SCENARIOS_INPUT: ${{ inputs.scenarios || '' }} + TEST_SETTINGS_INPUT: ${{ inputs.test_settings || '' }} + BASELINE_SETTINGS_INPUT: ${{ inputs.baseline_settings || '' }} + +jobs: + # ── Shared setup: build once, cache everything ────────────────────── + setup: + name: Build & Cache + runs-on: ubuntu-latest + timeout-minutes: 30 + outputs: + test_is_version: ${{ steps.resolve.outputs.is_version }} + test_build_arg: ${{ steps.resolve.outputs.build_arg }} + steps: + - name: Resolve test build type + id: resolve + run: | + INPUT="$TEST_BUILD_INPUT" + if [[ -z "$INPUT" ]]; then + echo "is_version=false" >> "$GITHUB_OUTPUT" + echo "build_arg=" >> "$GITHUB_OUTPUT" + elif [[ "$INPUT" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || [[ "$INPUT" == "insiders" ]] || [[ "$INPUT" == "stable" ]]; then + echo "test_build is a version string: $INPUT (will download)" + echo "is_version=true" >> "$GITHUB_OUTPUT" + echo "build_arg=$INPUT" >> "$GITHUB_OUTPUT" + else + echo "test_build is a git ref: $INPUT (will checkout and build from source)" + echo "is_version=false" >> "$GITHUB_OUTPUT" + echo "build_arg=" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ steps.resolve.outputs.is_version != 'true' && inputs.test_build || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install system dependencies + run: | + sudo apt update -y + sudo apt install -y \ + build-essential pkg-config \ + libx11-dev libx11-xcb-dev libxkbfile-dev \ + libnotify-bin libkrb5-dev \ + xvfb sqlite3 \ + libnss3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2t64 libdrm2 libxcomposite1 libxdamage1 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + libasound2t64 libxshmfence1 libgtk-3-0 + + - name: Install dependencies + run: npm ci + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install build dependencies + run: npm ci + working-directory: build + + - name: Transpile source + run: npm run transpile-client + + - name: Build copilot extension + run: npm run compile + working-directory: extensions/copilot + + - name: Download Electron + run: node build/lib/preLaunch.ts + + - name: Cache Electron + uses: actions/cache/save@v4 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }} + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Cache Playwright + uses: actions/cache/save@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + + - name: Upload build output + uses: actions/upload-artifact@v7 + with: + name: build-output + path: | + out/ + extensions/copilot/dist/ + retention-days: 1 + + # ── Perf comparison (split across matrix groups) ───────────────────── + chat-perf: + name: Chat Perf (${{ matrix.group }}) + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + group: [ 1, 2, 3, 4 ] + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install system dependencies + run: | + sudo apt update -y + sudo apt install -y \ + build-essential pkg-config \ + libx11-dev libx11-xcb-dev libxkbfile-dev \ + libnotify-bin libkrb5-dev \ + xvfb sqlite3 \ + libnss3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2t64 libdrm2 libxcomposite1 libxdamage1 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + libasound2t64 libxshmfence1 libgtk-3-0 + + - name: Install dependencies + run: npm ci + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Download build output + uses: actions/download-artifact@v7 + with: + name: build-output + + - name: Restore Electron cache + uses: actions/cache/restore@v4 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }} + + - name: Download Electron + run: node build/lib/preLaunch.ts + + - name: Restore Playwright cache + uses: actions/cache/restore@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Resolve scenario group + id: scenarios + run: | + node -e " + const fs = require('fs'); + require('./scripts/chat-simulation/common/perf-scenarios').registerPerfScenarios(); + const { getScenarioIds } = require('./scripts/chat-simulation/common/mock-llm-server'); + const userInput = process.env.SCENARIOS_INPUT || ''; + const allScens = userInput + ? userInput.split(',').map(s => s.trim()).filter(Boolean) + : getScenarioIds(); + const groups = 4; + const group = parseInt(process.env.MATRIX_GROUP, 10); + // Distribute scenarios round-robin across groups + const groupScens = allScens.filter((_, i) => (i % groups) + 1 === group); + if (groupScens.length === 0) { + console.log('No scenarios for group ' + group); + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip=true\n'); + } else { + const args = groupScens.map(s => '--scenario ' + s).join(' '); + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'skip=false\n'); + fs.appendFileSync(process.env.GITHUB_OUTPUT, 'args=' + args + '\n'); + console.log('Group ' + group + ' (' + groupScens.length + '/' + allScens.length + '): ' + groupScens.join(', ')); + } + " + env: + MATRIX_GROUP: ${{ matrix.group }} + + - name: Run chat perf comparison + id: perf + if: steps.scenarios.outputs.skip != 'true' + env: + SCENARIO_ARGS: ${{ steps.scenarios.outputs.args }} + run: | + PERF_ARGS=("--ci") + if [[ -n "$BASELINE_BUILD_INPUT" ]]; then + PERF_ARGS+=("--baseline-build" "$BASELINE_BUILD_INPUT") + fi + TEST_BUILD_ARG="${{ needs.setup.outputs.test_build_arg }}" + if [[ -n "$TEST_BUILD_ARG" ]]; then + PERF_ARGS+=("--build" "$TEST_BUILD_ARG") + fi + if [[ -n "$PERF_RUNS" ]]; then + PERF_ARGS+=("--runs" "$PERF_RUNS") + fi + if [[ -n "$PERF_THRESHOLD" ]]; then + PERF_ARGS+=("--threshold" "$PERF_THRESHOLD") + fi + PERF_ARGS+=("--production-build") + + # Convert JSON settings objects to --test-setting / --baseline-setting flags + if [[ -n "$TEST_SETTINGS_INPUT" ]]; then + while IFS='=' read -r key value; do + PERF_ARGS+=("--test-setting" "$key=$value") + done < <(node -e "const s=JSON.parse(process.env.TEST_SETTINGS_INPUT); for (const [k,v] of Object.entries(s)) console.log(k+'='+v)") + fi + if [[ -n "$BASELINE_SETTINGS_INPUT" ]]; then + while IFS='=' read -r key value; do + PERF_ARGS+=("--baseline-setting" "$key=$value") + done < <(node -e "const s=JSON.parse(process.env.BASELINE_SETTINGS_INPUT); for (const [k,v] of Object.entries(s)) console.log(k+'='+v)") + fi + + # Split SCENARIO_ARGS on whitespace into array elements + read -ra SCENARIO_ARR <<< "$SCENARIO_ARGS" + + set +eo pipefail + xvfb-run node scripts/chat-simulation/test-chat-perf-regression.js \ + "${PERF_ARGS[@]}" \ + "${SCENARIO_ARR[@]}" \ + 2>&1 | tee perf-output.log + echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Upload perf results + if: always() && steps.scenarios.outputs.skip != 'true' + uses: actions/upload-artifact@v7 + with: + name: perf-results-${{ matrix.group }} + include-hidden-files: true + path: | + perf-output.log + .chat-simulation-data/ + retention-days: 30 + + - name: Check for regressions + if: always() && steps.perf.outputs.exit_code != '' && + steps.perf.outputs.exit_code != '0' + run: | + echo "::error::Chat perf regression detected (exit code ${{ steps.perf.outputs.exit_code }}). See perf-output.log for details." + exit 1 + + # ── Memory leak check (runs in parallel with perf) ────────────────── + leak-check: + name: Leak Check + needs: setup + if: inputs.skip_leak_check != true + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: npm + + - name: Install system dependencies + run: | + sudo apt update -y + sudo apt install -y \ + build-essential pkg-config \ + libx11-dev libx11-xcb-dev libxkbfile-dev \ + libnotify-bin libkrb5-dev \ + xvfb \ + libnss3 libatk1.0-0 libatk-bridge2.0-0 \ + libcups2t64 libdrm2 libxcomposite1 libxdamage1 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + libasound2t64 libxshmfence1 libgtk-3-0 + + - name: Install dependencies + run: npm ci + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Download build output + uses: actions/download-artifact@v7 + with: + name: build-output + + - name: Restore Electron cache + uses: actions/cache/restore@v4 + with: + path: ~/.cache/electron + key: electron-${{ runner.os }}-${{ hashFiles('.nvmrc', 'package.json') }} + + - name: Download Electron + run: node build/lib/preLaunch.ts + + - name: Restore Playwright cache + uses: actions/cache/restore@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('package.json') }} + + - name: Install Playwright Chromium + run: npx playwright install chromium + + - name: Run memory leak check + id: leak + run: | + LEAK_ARGS="--verbose --ci" + TEST_BUILD_ARG="${{ needs.setup.outputs.test_build_arg }}" + if [[ -n "$TEST_BUILD_ARG" ]]; then + LEAK_ARGS="$LEAK_ARGS --build $TEST_BUILD_ARG" + fi + + set +eo pipefail + xvfb-run node scripts/chat-simulation/test-chat-mem-leaks.js \ + $LEAK_ARGS \ + 2>&1 | tee leak-output.log + echo "exit_code=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT" + + - name: Upload leak results + if: always() + uses: actions/upload-artifact@v7 + with: + name: leak-results + include-hidden-files: true + path: | + leak-output.log + .chat-simulation-data/chat-simulation-leak-results.json + .chat-simulation-data/ci-summary-leak.md + retention-days: 30 + + - name: Check for leaks + if: always() && steps.leak.outputs.exit_code != '' && + steps.leak.outputs.exit_code != '0' + run: | + echo "::error::Chat memory leak detected (exit code ${{ steps.leak.outputs.exit_code }}). See leak-output.log for details." + exit 1 + + # ── Report: collect results, write summary, fail on regression ────── + report: + name: Report + needs: [ chat-perf, leak-check ] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ needs.setup.outputs.test_is_version != 'true' && inputs.test_build || github.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - name: Download all perf results + uses: actions/download-artifact@v7 + with: + pattern: perf-results-* + path: perf-results + + - name: Download leak results + if: inputs.skip_leak_check != true && needs.leak-check.result != 'skipped' + uses: actions/download-artifact@v7 + with: + name: leak-results + path: leak-results + continue-on-error: true + + - name: Generate unified summary + run: | + LEAK_ARG="" + if [[ -f leak-results/.chat-simulation-data/ci-summary-leak.md ]]; then + LEAK_ARG="--leak-summary leak-results/.chat-simulation-data/ci-summary-leak.md" + fi + + node scripts/chat-simulation/merge-ci-summary.js \ + --results-dir perf-results \ + --output ci-summary.md \ + $LEAK_ARG + + cat ci-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload CI summary + if: always() + uses: actions/upload-artifact@v7 + with: + name: chat-perf-summary + path: ci-summary.md + retention-days: 30 + + - name: Fail on regression + id: regression + if: needs.chat-perf.result == 'failure' || (inputs.skip_leak_check != true && + needs.leak-check.result == 'failure') + run: | + if [[ "${{ needs.chat-perf.result }}" == "failure" ]]; then + echo "::error::Chat performance regression detected. See job summary for details." + fi + if [[ "${{ inputs.skip_leak_check }}" != "true" && "${{ needs.leak-check.result }}" == "failure" ]]; then + echo "::error::Chat memory leak detected. See leak-output.log for details." + fi + exit 1 diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index cec3e2ac0bf00..430d8c9f03a14 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -26,6 +26,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Depth 2 gives us the test-merge commit plus its parents, which is + # enough for the merge-base lookup below (the target-branch tip is a + # direct parent). Full clone would be wasteful for this large repo. + fetch-depth: 2 - name: Setup Node.js uses: actions/setup-node@v6 @@ -135,11 +140,20 @@ jobs: id: diff if: github.event_name == 'pull_request' && steps.oidc.outputs.token run: | + # We diff screenshots(checked-out commit) vs screenshots(merge-base of + # that commit with the target branch). This isolates the visual effect + # of just this PR's divergence from target. Using pull_request.base.sha + # would be wrong: it's the target-branch tip at PR creation time and can + # be stale, causing unrelated target-branch commits to show up as diffs. + TARGET_REF="origin/${{ github.event.pull_request.base.ref }}" + git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" + BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF") + echo "Using base SHA: $BASE_SHA (merge-base of ${{ github.sha }} and $TARGET_REF)" BODY=$(node build/lib/screenshotDiffReport.ts \ https://hediet-screenshots.azurewebsites.net \ ${{ github.repository_owner }} \ ${{ github.event.repository.name }} \ - ${{ github.event.pull_request.base.sha }} \ + "$BASE_SHA" \ ${{ github.sha }}) if [ -n "$BODY" ]; then echo "has_changes=true" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 9e762db2a0631..0acd0a3cb78f4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ product.overrides.json *.snap.actual *.tsbuildinfo .vscode-test +.chat-simulation-data vscode-telemetry-docs/ test-output.json test/componentFixtures/.screenshots/* diff --git a/build/azure-pipelines/product-copilot.yml b/build/azure-pipelines/product-copilot.yml index e9e428bd99ed6..deda66cfe852c 100644 --- a/build/azure-pipelines/product-copilot.yml +++ b/build/azure-pipelines/product-copilot.yml @@ -149,3 +149,23 @@ jobs: done echo "Successfully uploaded $UPLOADED source maps" + + # Upload commit-to-version mapping so the deminify service + # can resolve the patched extension version from a VS Code commit hash. + COMMIT_HASH="$(Build.SourceVersion)" + MAPPING_BLOB="sourcemaps/${EXTENSION_ID}/commits/${COMMIT_HASH}.json" + echo "{\"version\":\"${VERSION}\",\"extensionId\":\"${EXTENSION_ID}\"}" > /tmp/commit-version.json + + echo "Uploading commit mapping: $COMMIT_HASH -> $VERSION" + az storage blob upload \ + --account-name "$STORAGE_ACCOUNT" \ + --container-name '$web' \ + --name "$MAPPING_BLOB" \ + --file "/tmp/commit-version.json" \ + --content-type "application/json" \ + --content-cache-control "no-cache, no-store, must-revalidate" \ + --auth-mode login \ + --overwrite \ + --only-show-errors + + echo "Commit mapping uploaded: $MAPPING_BLOB" diff --git a/build/filters.ts b/build/filters.ts index 71587859a47bf..f43780b6b182f 100644 --- a/build/filters.ts +++ b/build/filters.ts @@ -163,6 +163,7 @@ export const copyrightFilter = Object.freeze([ '**', '!**/*.desktop', '!**/*.json', + '!**/*.jsonc', '!**/*.jsonl', '!**/*.html', '!**/*.template', diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 99f319ef9329e..8c2f5658ea121 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -200,7 +200,7 @@ "localization": { "description": { "key": "chat.agent.networkFilter", - "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. When disabled, no network filtering is applied." + "value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled." } }, "type": "boolean", @@ -215,7 +215,7 @@ "localization": { "description": { "key": "chat.agent.allowedNetworkDomains", - "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Only takes effect when `#chat.agent.networkFilter#` is enabled. When `#chat.agent.sandbox.enabled#` is enabled, these also apply to the terminal sandbox. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence." + "value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.enabled#` is enabled, this also configures terminal sandbox networking. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence." } }, "type": "array", @@ -230,7 +230,7 @@ "localization": { "description": { "key": "chat.agent.deniedNetworkDomains", - "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Only takes effect when `#chat.agent.networkFilter#` is enabled. When `#chat.agent.sandbox.enabled#` is enabled, these also apply to the terminal sandbox. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`." + "value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.enabled#` is enabled, this also configures terminal sandbox networking. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`." } }, "type": "array", diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index b2bf38ec6c721..59dfc331f3501 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -936,6 +936,8 @@ "--background-light", "--chat-editing-last-edit-shift", "--chat-current-response-min-height", + "--chat-smooth-delay", + "--chat-smooth-duration", "--inline-chat-frame-progress", "--insert-border-color", "--last-tab-margin-right", diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index fc330131170d7..ea516c11a5fc2 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -9,8 +9,8 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@vscode/component-explorer": "^0.2.1-8", - "@vscode/component-explorer-webpack-plugin": "^0.3.1-5" + "@vscode/component-explorer": "^0.2.1-17", + "@vscode/component-explorer-webpack-plugin": "^0.3.1-14" }, "devDependencies": { "@rspack/cli": "^1.3.18", @@ -1108,22 +1108,22 @@ } }, "node_modules/@vscode/component-explorer": { - "version": "0.2.1-8", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-8.tgz", - "integrity": "sha512-yIBSrKe8rR/NU/vh7Lj3RYK38foI7BayALT/T2IL+R8EGAlnNZ4sKyAj16k7+ABiW9iDBTlqlUV9pmxK+e6XIA==", + "version": "0.2.1-17", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-17.tgz", + "integrity": "sha512-bDj9dKpgwjZwyoWF0hiP0Ee7YzzOVFBXj+/mk4dmNMAAKeAGaqgKFCFlDTYoDW8axmywq5dtA0enPdziTPKnPA==", "license": "MIT", "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.3.1", + "react-dom": "^18.3.1" } }, "node_modules/@vscode/component-explorer-webpack-plugin": { - "version": "0.3.1-5", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-webpack-plugin/-/component-explorer-webpack-plugin-0.3.1-5.tgz", - "integrity": "sha512-Qz7k4HszbpFDsC3e1I6IBmY9xExkQUU2NyVPvdb7OsLSV7PcrlO39rakhS5kdy9Lg4ipZFdpqfhLSupSTHTx7g==", + "version": "0.3.1-14", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-webpack-plugin/-/component-explorer-webpack-plugin-0.3.1-14.tgz", + "integrity": "sha512-j4vlpy3xuywwLmPmz+P7UgesmnlCzwB4xEO+C115cCSJm3pjhbKtnyO4uo1iLMRNCPE+lvcmFMKEYq7cFHlAvQ==", "license": "MIT", "dependencies": { - "tinyglobby": "^0.2.0" + "tinyglobby": "^0.2.16" }, "peerDependencies": { "@vscode/component-explorer": "*" @@ -3987,13 +3987,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/build/rspack/package.json b/build/rspack/package.json index 46065458494df..82e74278001df 100644 --- a/build/rspack/package.json +++ b/build/rspack/package.json @@ -12,7 +12,7 @@ "@vscode/esm-url-webpack-plugin": "^1.0.1-3" }, "dependencies": { - "@vscode/component-explorer": "^0.2.1-8", - "@vscode/component-explorer-webpack-plugin": "^0.3.1-5" + "@vscode/component-explorer": "^0.2.1-17", + "@vscode/component-explorer-webpack-plugin": "^0.3.1-14" } } diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 46cddd287752e..3160a75630868 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,8 +8,8 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/component-explorer": "^0.2.1-8", - "@vscode/component-explorer-vite-plugin": "^0.2.1-7", + "@vscode/component-explorer": "^0.2.1-17", + "@vscode/component-explorer-vite-plugin": "^0.2.1-16", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" @@ -683,24 +683,24 @@ "license": "MIT" }, "node_modules/@vscode/component-explorer": { - "version": "0.2.1-8", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-8.tgz", - "integrity": "sha512-yIBSrKe8rR/NU/vh7Lj3RYK38foI7BayALT/T2IL+R8EGAlnNZ4sKyAj16k7+ABiW9iDBTlqlUV9pmxK+e6XIA==", + "version": "0.2.1-17", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-17.tgz", + "integrity": "sha512-bDj9dKpgwjZwyoWF0hiP0Ee7YzzOVFBXj+/mk4dmNMAAKeAGaqgKFCFlDTYoDW8axmywq5dtA0enPdziTPKnPA==", "dev": true, "license": "MIT", "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.3.1", + "react-dom": "^18.3.1" } }, "node_modules/@vscode/component-explorer-vite-plugin": { - "version": "0.2.1-7", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.2.1-7.tgz", - "integrity": "sha512-um4aCYWfbi8JqZIl8j1iAt7j/L1f/VX5jo+atmpuSe1wxrbxfYgFE/WA98JGNTOzJ190ddU2RkRyRvRcoU9tUA==", + "version": "0.2.1-16", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.2.1-16.tgz", + "integrity": "sha512-mSDHVskTI5DoOApHoQgDa0FER5LgTdWrXA5+QimFVf2u9DDKE6PRaj3wow2S9f9hDguGHodLZ9u3YhMXrxHd2Q==", "dev": true, "license": "MIT", "dependencies": { - "tinyglobby": "^0.2.0" + "tinyglobby": "^0.2.16" }, "peerDependencies": { "@vscode/component-explorer": "*", @@ -1234,14 +1234,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/build/vite/package.json b/build/vite/package.json index b6a9a3896f955..e846dba14274e 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,8 +9,8 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/component-explorer": "^0.2.1-8", - "@vscode/component-explorer-vite-plugin": "^0.2.1-7", + "@vscode/component-explorer": "^0.2.1-17", + "@vscode/component-explorer-vite-plugin": "^0.2.1-16", "@vscode/rollup-plugin-esm-url": "^1.0.1-1", "rollup": "*", "vite": "npm:rolldown-vite@latest" diff --git a/cli/Cargo.lock b/cli/Cargo.lock index e50f85de23aa3..93862f3137794 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -427,7 +427,7 @@ dependencies = [ "open", "opentelemetry", "pin-project", - "rand 0.8.5", + "rand 0.9.3", "regex", "reqwest", "rmp-serde", @@ -2230,6 +2230,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2250,6 +2260,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2268,6 +2288,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6f54ec61cbb58..0389746bbeca0 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -28,7 +28,7 @@ serde_json = "1.0.96" rmp-serde = "1.1.1" uuid = { version = "1.4", features = ["serde", "v4"] } dirs = "5.0.1" -rand = "0.8.5" +rand = "0.9.3" opentelemetry = { version = "0.19.0", features = ["rt-tokio"] } serde_bytes = "0.11.9" chrono = { version = "0.4.26", features = ["serde", "std", "clock"], default-features = false } diff --git a/cli/src/tunnels/challenge.rs b/cli/src/tunnels/challenge.rs index 81540004844e2..d15abed2cf8e6 100644 --- a/cli/src/tunnels/challenge.rs +++ b/cli/src/tunnels/challenge.rs @@ -5,8 +5,8 @@ #[cfg(not(feature = "vsda"))] pub fn create_challenge() -> String { - use rand::distributions::{Alphanumeric, DistString}; - Alphanumeric.sample_string(&mut rand::thread_rng(), 16) + use rand::distr::{Alphanumeric, SampleString}; + Alphanumeric.sample_string(&mut rand::rng(), 16) } #[cfg(not(feature = "vsda"))] @@ -26,8 +26,8 @@ pub fn verify_challenge(challenge: &str, response: &str) -> bool { #[cfg(feature = "vsda")] pub fn create_challenge() -> String { - use rand::distributions::{Alphanumeric, DistString}; - let str = Alphanumeric.sample_string(&mut rand::thread_rng(), 16); + use rand::distr::{Alphanumeric, SampleString}; + let str = Alphanumeric.sample_string(&mut rand::rng(), 16); vsda::create_new_message(&str) } diff --git a/cli/src/tunnels/code_server.rs b/cli/src/tunnels/code_server.rs index bbabadcf90a20..ffabbad19c433 100644 --- a/cli/src/tunnels/code_server.rs +++ b/cli/src/tunnels/code_server.rs @@ -23,7 +23,7 @@ use crate::util::http::{self, BoxedHttp}; use crate::util::io::SilentCopyProgress; use crate::util::machine::process_exists; use crate::util::prereqs::skip_requirements_check; -use crate::{debug, info, log, spanf, trace, warning}; +use crate::log; use lazy_static::lazy_static; use opentelemetry::KeyValue; use regex::Regex; diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index 0168ee60d8965..bc043cd62af3b 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -11,7 +11,7 @@ use crate::util::errors::{ WrappedError, }; use crate::util::input::prompt_placeholder; -use crate::{debug, info, log, spanf, trace, warning}; +use crate::log; use async_trait::async_trait; use futures::future::BoxFuture; use futures::{FutureExt, TryFutureExt}; @@ -694,7 +694,7 @@ impl DevTunnels { let recyclable = existing_tunnels .iter() .filter(|t| !tunnel_has_host_connection(t)) - .choose(&mut rand::thread_rng()); + .choose(&mut rand::rng()); match recyclable { Some(tunnel) => { diff --git a/cli/src/tunnels/protocol.rs b/cli/src/tunnels/protocol.rs index 3654826c57ebe..0c6329f30439f 100644 --- a/cli/src/tunnels/protocol.rs +++ b/cli/src/tunnels/protocol.rs @@ -299,9 +299,10 @@ pub enum PortPrivacy { Private, } -#[derive(Serialize, Deserialize, PartialEq, Copy, Eq, Clone, Debug)] +#[derive(Serialize, Deserialize, PartialEq, Copy, Eq, Clone, Debug, Default)] #[serde(rename_all = "lowercase")] pub enum PortProtocol { + #[default] Auto, Http, Https, @@ -313,12 +314,6 @@ impl std::fmt::Display for PortProtocol { } } -impl Default for PortProtocol { - fn default() -> Self { - Self::Auto - } -} - impl PortProtocol { pub fn to_contract_str(&self) -> &'static str { match *self { diff --git a/cli/src/update_service.rs b/cli/src/update_service.rs index 55f1dadccdf67..de977b736b20a 100644 --- a/cli/src/update_service.rs +++ b/cli/src/update_service.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use crate::{ constants::VSCODE_CLI_UPDATE_ENDPOINT, - debug, log, options, spanf, + log, options, util::{ errors::{wrap, AnyError, CodeError, WrappedError}, http::{BoxedHttp, SimpleResponse}, diff --git a/cli/src/util/io.rs b/cli/src/util/io.rs index 4b8118a219f81..2de2a72583ea9 100644 --- a/cli/src/util/io.rs +++ b/cli/src/util/io.rs @@ -310,7 +310,7 @@ mod tests { .truncate(true) .open(&file_path) .unwrap(); - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); let mut written = vec![]; let base_line = "Elit ipsum cillum ex cillum. Adipisicing consequat cupidatat do proident ut in sunt Lorem ipsum tempor. Eiusmod ipsum Lorem labore exercitation sunt pariatur excepteur fugiat cillum velit cillum enim. Nisi Lorem cupidatat ad enim velit officia eiusmod esse tempor aliquip. Deserunt pariatur tempor in duis culpa esse sit nulla irure ullamco ipsum voluptate non laboris. Occaecat officia nulla officia mollit do aliquip reprehenderit ad incididunt."; diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index b2861d59a2b07..ecfdab0a3d389 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -43,7 +43,7 @@ "applicationinsights": "^2.9.7", "best-effort-json-parser": "^1.2.1", "diff": "^8.0.3", - "dompurify": "^3.3.2", + "dompurify": "^3.4.0", "express": "^5.2.1", "ignore": "^7.0.5", "isbinaryfile": "^5.0.4", @@ -11217,13 +11217,10 @@ } }, "node_modules/dompurify": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", - "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", - "engines": { - "node": ">=20" - }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 66acbd2149f84..8ca96b4cbf046 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6171,70 +6171,61 @@ "chatPromptFiles": [ { "path": "./assets/prompts/plan.prompt.md", - "when": "chatSessionType == local", "sessionTypes": ["local"] } ], "chatSkills": [ { "path": "./assets/prompts/skills/project-setup-info-local/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.projectSetupInfoSkill.enabled && !config.github.copilot.chat.newWorkspace.useContext7", + "when": "config.github.copilot.chat.projectSetupInfoSkill.enabled && !config.github.copilot.chat.newWorkspace.useContext7", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/project-setup-info-context7/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.projectSetupInfoSkill.enabled && config.github.copilot.chat.newWorkspace.useContext7", + "when": "config.github.copilot.chat.projectSetupInfoSkill.enabled && config.github.copilot.chat.newWorkspace.useContext7", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/install-vscode-extension/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.installExtensionSkill.enabled && config.github.copilot.chat.newWorkspaceCreation.enabled", + "when": "config.github.copilot.chat.installExtensionSkill.enabled && config.github.copilot.chat.newWorkspaceCreation.enabled", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/get-search-view-results/SKILL.md", - "when": "chatSessionType == local && config.github.copilot.chat.getSearchViewResultsSkill.enabled", + "when": "config.github.copilot.chat.getSearchViewResultsSkill.enabled", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/troubleshoot/SKILL.md", - "when": "chatSessionType == local || chatSessionType == copilotcli", "sessionTypes": ["local", "copilotcli"] }, { "path": "./assets/prompts/skills/agent-customization/SKILL.md", - "when": "chatSessionType == local || chatSessionType == copilotcli", "sessionTypes": ["local", "copilotcli"] }, { "path": "./assets/prompts/skills/init/SKILL.md", - "when": "chatSessionType == local", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-prompt/SKILL.md", - "when": "chatSessionType == local", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-instructions/SKILL.md", - "when": "chatSessionType == local", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-skill/SKILL.md", - "when": "chatSessionType == local", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-agent/SKILL.md", - "when": "chatSessionType == local", "sessionTypes": ["local"] }, { "path": "./assets/prompts/skills/create-hook/SKILL.md", - "when": "chatSessionType == local", "sessionTypes": ["local"] } ], @@ -6427,7 +6418,7 @@ "applicationinsights": "^2.9.7", "best-effort-json-parser": "^1.2.1", "diff": "^8.0.3", - "dompurify": "^3.3.2", + "dompurify": "^3.4.0", "express": "^5.2.1", "ignore": "^7.0.5", "isbinaryfile": "^5.0.4", diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts index 7e8c92198f5f8..78d876be13f91 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts @@ -18,7 +18,9 @@ export interface RepositoryProperties { readonly repositoryPath: string; readonly branchName?: string; readonly baseBranchName?: string; + readonly baseCommit?: string; readonly upstreamBranchName?: string; + readonly mergeBaseCommit?: string; readonly hasGitHubRemote?: boolean; readonly incomingChanges?: number; readonly outgoingChanges?: number; diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts index 15df93ab2a4fa..4a76bf2288cac 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts @@ -41,6 +41,7 @@ export interface ChatSessionWorktreePropertiesV2 extends ChatSessionWorktreeBase readonly baseBranchName: string; readonly baseBranchProtected?: boolean; readonly upstreamBranchName?: string; + readonly mergeBaseCommit?: string; readonly pullRequestUrl?: string; readonly pullRequestState?: string; readonly firstCheckpointRef?: string; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts index 0bb7634abc11b..22b2f64f17df7 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCLISkills.ts @@ -22,6 +22,7 @@ import { URI } from '../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; +import { isEnabledForCopilotCLI } from './copilotCli'; export interface ICopilotCLISkills { readonly _serviceBrand: undefined; @@ -70,6 +71,7 @@ export class CopilotCLISkills extends Disposable implements ICopilotCLISkills { } } (await this.promptsService.getSkills(token)) + .filter(isEnabledForCopilotCLI) .filter(s => s.uri.scheme === Schemas.file) .map(s => s.uri) .map(uri => dirname(dirname(uri))) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts index 462ca59ea183d..8c7cdd799377e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorkspaceFolderServiceImpl.ts @@ -141,6 +141,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh if (properties) { await this.metadataStore.storeRepositoryProperties(sessionId, { ...repositoryProperties, + mergeBaseCommit: properties.mergeBaseCommit, hasGitHubRemote: properties.hasGitHubRemote, upstreamBranchName: properties.upstreamBranchName, incomingChanges: properties.incomingChanges, @@ -156,6 +157,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh private async computeWorkspaceChanges(repositoryProperties: RepositoryProperties, sessionId: string): Promise<{ readonly changes: ChatSessionWorktreeFile[]; + readonly mergeBaseCommit?: string; readonly hasGitHubRemote?: boolean; readonly upstreamBranchName?: string; readonly incomingChanges?: number; @@ -242,6 +244,18 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh } } + // Since the diff may be computed using the merge base commit of the current + // branch and the base branch, we need to compute it as well so that we can use + // it as the originalRef (left-hand side) of the diff editor + let mergeBaseCommit: string | undefined; + try { + if (repositoryProperties.branchName && repositoryProperties.baseBranchName) { + mergeBaseCommit = await this.gitService.getMergeBase(repository.rootUri, repositoryProperties.branchName, repositoryProperties.baseBranchName); + } + } catch (error) { + this.logService.error(`[ChatSessionWorkspaceFolderService][getWorkspaceChanges] Error while getting merge base (${repositoryProperties.branchName}, ${repositoryProperties.baseBranchName}): ${error}`); + } + const changes = diffChanges.map(change => ({ filePath: change.uri.fsPath, originalFilePath: change.status !== 1 /* INDEX_ADDED */ @@ -257,6 +271,7 @@ export class ChatSessionWorkspaceFolderService extends Disposable implements ICh } satisfies ChatSessionWorktreeFile)); const repositoryState = { + mergeBaseCommit, hasGitHubRemote: getGitHubRepoInfoFromContext(repository) !== undefined, upstreamBranchName: repository.upstreamRemote && repository.upstreamBranchName ? `${repository.upstreamRemote}/${repository.upstreamBranchName}` diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index 4624cad738383..0a23e98bea82b 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -131,6 +131,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi upstreamBranchName: activeRepository.upstreamRemote && activeRepository.upstreamBranchName ? `${activeRepository.upstreamRemote}/${activeRepository.upstreamBranchName}` : undefined, + mergeBaseCommit: baseCommit ?? activeRepository.headCommitHash, hasGitHubRemote: gitHubRemote !== undefined, incomingChanges, outgoingChanges, @@ -688,6 +689,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi private async _getWorktreeChanges(sessionId: string, worktreeProperties: ChatSessionWorktreeProperties): Promise<{ readonly changes: readonly ChatSessionWorktreeFile[]; + readonly mergeBaseCommit?: string; readonly hasGitHubRemote?: boolean; readonly upstreamBranchName?: string; readonly incomingChanges?: number; @@ -769,6 +771,16 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } } + // Since the diff is being computed using the merge base commit of the worktree + // branch and the base branch, we need to compute it as well so that we can use + // it as the originalRef (left-hand side) of the diff editor + let mergeBaseCommit: string | undefined; + try { + mergeBaseCommit = await this.gitService.getMergeBase(worktreePath, worktreeProperties.branchName, worktreeProperties.baseBranchName); + } catch (error) { + this.logService.error(`[ChatSessionWorktreeService][_getWorktreeChanges] Error while getting merge base (${worktreeProperties.branchName}, ${worktreeProperties.baseBranchName}) for session ${sessionId}: ${error}`); + } + const changes = diffChanges.map(change => ({ filePath: change.uri.fsPath, originalFilePath: change.status !== 1 /* INDEX_ADDED */ @@ -784,6 +796,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } satisfies ChatSessionWorktreeFile)); const repositoryState = { + mergeBaseCommit, hasGitHubRemote: getGitHubRepoInfoFromContext(worktreeRepository) !== undefined, upstreamBranchName: worktreeRepository.upstreamRemote && worktreeRepository.upstreamBranchName ? `${worktreeRepository.upstreamRemote}/${worktreeRepository.upstreamBranchName}` @@ -805,7 +818,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi if (worktreeProperties.version === 2) { // Commit | Working tree originalFileRef = vscode.workspace.isAgentSessionsWorkspace - ? worktreeProperties.baseBranchName + ? worktreeProperties.mergeBaseCommit ?? worktreeProperties.baseCommit : worktreeProperties.baseCommit; modifiedFileRef = vscode.workspace.isAgentSessionsWorkspace ? undefined diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 0ad22b01f1b09..5293b7a177fb2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import * as vscode from 'vscode'; import { ChatExtendedRequestHandler } from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; @@ -12,7 +11,7 @@ import { IGitService } from '../../../platform/git/common/gitService'; import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; -import { Disposable, IDisposable } from '../../../util/vs/base/common/lifecycle'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; @@ -27,7 +26,6 @@ import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCom import { IChatFolderMruService } from '../common/folderRepositoryManager'; import { buildChatHistory } from './chatHistoryBuilder'; import { ClaudeSessionOptionBuilder, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder'; -import { getSelectedOption } from './sessionOptionGroupBuilder'; // Import the tool permission handlers import '../claude/vscode-node/toolPermissionHandlers/index'; @@ -103,8 +101,14 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco } const modelId = parseClaudeModelId(request.model.id); - const permissionMode = this._controller.getPermissionModeForSession(effectiveSessionId); - const folderInfo = await this._controller.getFolderInfoForSession(effectiveSessionId); + const selectedPermissionId = chatSessionContext.inputState.groups.find(group => group.id === PERMISSION_MODE_OPTION_ID)?.selected?.id; + if (!selectedPermissionId || !isPermissionMode(selectedPermissionId)) { + throw new Error(`Permission mode not set for session ${effectiveSessionId}`); + } + const permissionMode = selectedPermissionId; + const selectedFolderId = chatSessionContext.inputState.groups.find(group => group.id === FOLDER_OPTION_ID)?.selected?.id; + const selectedFolderUri = selectedFolderId ? URI.file(selectedFolderId) : undefined; + const folderInfo = await this._controller.getFolderInfoForSession(effectiveSessionId, selectedFolderUri); // Commit UI state to session state service before invoking agent manager this.sessionStateService.setModelIdForSession(effectiveSessionId, modelId); @@ -163,9 +167,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco * Reads sessions from ~/.claude/projects//, where each file name is a session id (GUID). * * Owns the input state (getChatSessionInputState) lifecycle: wiring external - * state listeners, persisting selections to metadata, and resolving permission - * mode / folder info for sessions. Group construction is delegated to - * {@link ClaudeSessionOptionBuilder}. + * state listeners and resolving folder info for sessions. Group construction + * is delegated to {@link ClaudeSessionOptionBuilder}. */ export class ClaudeChatSessionItemController extends Disposable { private readonly _controller: vscode.ChatSessionItemController; @@ -200,16 +203,6 @@ export class ClaudeChatSessionItemController extends Disposable { ); item.iconPath = new vscode.ThemeIcon('claude'); item.timing = { created: Date.now() }; - - const permissionModeSelection = getSelectedOption(context.inputState.groups, PERMISSION_MODE_OPTION_ID); - const permissionMode = permissionModeSelection?.id; - const folderSelection = getSelectedOption(context.inputState.groups, FOLDER_OPTION_ID); - const folder = folderSelection?.id ? URI.file(folderSelection.id) : undefined; - - item.metadata = { - permissionMode, - cwd: folder, - }; this._inProgressItems.set(newSessionId, item); return item; }; @@ -248,7 +241,19 @@ export class ClaudeChatSessionItemController extends Disposable { const newItem = this._controller.createChatSessionItem(ClaudeSessionUri.forSessionId(result.sessionId), title); newItem.iconPath = new vscode.ThemeIcon('claude'); newItem.timing = { created: Date.now() }; - newItem.metadata = item?.metadata ? { ...item.metadata } : undefined; + + // Copy parent session state to the forked session + const parentSessionId = ClaudeSessionUri.getSessionId(sessionResource); + const parentPermission = this._sessionStateService.getPermissionModeForSession(parentSessionId); + const parentFolder = this._sessionStateService.getFolderInfoForSession(parentSessionId); + this._sessionStateService.setPermissionModeForSession(result.sessionId, parentPermission); + if (parentFolder) { + this._sessionStateService.setFolderInfoForSession(result.sessionId, { + ...parentFolder, + additionalDirectories: [...(parentFolder.additionalDirectories ?? [])], + }); + } + this._controller.items.add(newItem); return newItem; }; @@ -273,44 +278,30 @@ export class ClaudeChatSessionItemController extends Disposable { // #region Input State private _setupInputState(): void { - const trackedStates: { ref: WeakRef; subscription: IDisposable }[] = []; + const trackedStates: { ref: WeakRef }[] = []; const sweepStaleEntries = () => { for (let i = trackedStates.length - 1; i >= 0; i--) { if (!trackedStates[i].ref.deref()) { - trackedStates[i].subscription.dispose(); trackedStates.splice(i, 1); } } }; - // Dispose all subscriptions when the content provider is disposed - this._register({ - dispose: () => { - for (const entry of trackedStates) { - entry.subscription.dispose(); - } - trackedStates.length = 0; + this._controller.getChatSessionInputState = async (sessionResource, context, token) => { + if (context.previousInputState) { + const state = this._controller.createChatSessionInputState([...context.previousInputState.groups]); + trackedStates.push({ ref: new WeakRef(state) }); + return state; } - }); - this._controller.getChatSessionInputState = async (sessionResource, context, token) => { const isExistingSession = sessionResource && await this._claudeCodeSessionService.getSession(sessionResource, token) !== undefined; const groups = isExistingSession ? await this._buildExistingSessionGroups(sessionResource) - : await this._optionBuilder.buildNewSessionGroups(context.previousInputState); + : await this._optionBuilder.buildNewSessionGroups(); const state = this._controller.createChatSessionInputState(groups); - - const ref = new WeakRef(state); - const subscription = state.onDidChange(() => { - const s = ref.deref(); - if (s) { - this._handleInputStateChange(s); - } - }); - trackedStates.push({ ref, subscription }); - + trackedStates.push({ ref: new WeakRef(state) }); return state; }; @@ -342,11 +333,6 @@ export class ClaudeChatSessionItemController extends Disposable { if (e.permissionMode === undefined) { return; } - const existingMode = this.getMetadata(e.sessionId)?.permissionMode; - if (e.permissionMode === existingMode) { - return; - } - this.setMetadata(e.sessionId, { permissionMode: e.permissionMode }); for (const entry of trackedStates) { const state = entry.ref.deref(); if (state?.sessionResource) { @@ -368,23 +354,24 @@ export class ClaudeChatSessionItemController extends Disposable { private async _buildExistingSessionGroups(sessionResource: vscode.Uri): Promise { const sessionId = ClaudeSessionUri.getSessionId(sessionResource); - const permissionMode = this.getPermissionModeForSession(sessionId); - const workspaceFolders = this._workspaceService.getWorkspaceFolders(); - const folderUri = workspaceFolders.length !== 1 ? await this._getDefaultFolderForSession(sessionId) : undefined; - return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri); - } + const permissionMode = this._sessionStateService.getPermissionModeForSession(sessionId); - private _handleInputStateChange(state: vscode.ChatSessionInputState): void { - const { permissionMode, folderUri } = this._optionBuilder.getSelections(state.groups); - const sessionId = state.sessionResource ? ClaudeSessionUri.getSessionId(state.sessionResource) : undefined; - if (sessionId) { - if (permissionMode) { - this.setMetadata(sessionId, { permissionMode }); - } - if (folderUri) { - this.setMetadata(sessionId, { cwd: folderUri }); + const workspaceFolders = this._workspaceService.getWorkspaceFolders(); + let folderUri: URI | undefined; + if (workspaceFolders.length !== 1) { + const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId); + if (stateFolder) { + folderUri = URI.file(stateFolder.cwd); + } else { + const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None); + if (session?.cwd) { + folderUri = URI.file(session.cwd); + } else { + folderUri = await this._optionBuilder.getDefaultFolder(); + } } } + return this._optionBuilder.buildExistingSessionGroups(permissionMode, folderUri); } private async _rebuildInputState(state: vscode.ChatSessionInputState): Promise { @@ -397,26 +384,9 @@ export class ClaudeChatSessionItemController extends Disposable { // #endregion - // #region Permission Mode & Folder Resolution + // #region Folder Resolution - private async _getDefaultFolderForSession(sessionId: string): Promise { - const selected = this.getMetadata(sessionId)?.cwd; - if (selected) { - return selected; - } - - const defaultFolder = await this._optionBuilder.getDefaultFolder(); - if (defaultFolder) { - this.setMetadata(sessionId, { cwd: defaultFolder }); - } - return defaultFolder; - } - - getPermissionModeForSession(sessionId: string): PermissionMode { - return this.getMetadata(sessionId)?.permissionMode ?? this._sessionStateService.getPermissionModeForSession(sessionId); - } - - async getFolderInfoForSession(sessionId: string): Promise { + async getFolderInfoForSession(sessionId: string, selectedFolderUri?: URI): Promise { const workspaceFolders = this._workspaceService.getWorkspaceFolders(); if (workspaceFolders.length === 1) { @@ -426,11 +396,11 @@ export class ClaudeChatSessionItemController extends Disposable { }; } - // Multi-root or empty workspace: use the selected folder - const selectedFolder = this.getMetadata(sessionId)?.cwd; + // Multi-root or empty workspace: resolve selected folder from inputState, sessionStateService, or session file + const folderUri = selectedFolderUri ?? await this._resolveSessionFolder(sessionId); if (workspaceFolders.length > 1) { - const cwd = selectedFolder?.fsPath ?? workspaceFolders[0].fsPath; + const cwd = folderUri?.fsPath ?? workspaceFolders[0].fsPath; const additionalDirectories = workspaceFolders .map(f => f.fsPath) .filter(p => p !== cwd); @@ -438,9 +408,9 @@ export class ClaudeChatSessionItemController extends Disposable { } // Empty workspace - if (selectedFolder) { + if (folderUri) { return { - cwd: selectedFolder.fsPath, + cwd: folderUri.fsPath, additionalDirectories: [], }; } @@ -461,45 +431,23 @@ export class ClaudeChatSessionItemController extends Disposable { }; } - // #endregion - - // #region Metadata - - setMetadata(sessionId: string, metadata: Partial<{ permissionMode: PermissionMode; cwd?: URI }>): void { - const item = this._controller.items.get(ClaudeSessionUri.forSessionId(sessionId)); - if (item) { - item.metadata = { - ...item.metadata, - permissionMode: metadata.permissionMode ?? item.metadata?.permissionMode, - cwd: metadata.cwd ?? item.metadata?.cwd, - }; + private async _resolveSessionFolder(sessionId: string): Promise { + const stateFolder = this._sessionStateService.getFolderInfoForSession(sessionId); + if (stateFolder) { + return URI.file(stateFolder.cwd); } - } - getMetadata(sessionId: string): { permissionMode?: PermissionMode; cwd?: URI } | undefined { - const candidate = this._controller.items.get(ClaudeSessionUri.forSessionId(sessionId)); - if (candidate) { - if (candidate.metadata?.permissionMode !== undefined && !isPermissionMode(candidate.metadata.permissionMode)) { - this._logService.warn(`Invalid permission mode "${candidate.metadata?.permissionMode}" found in metadata for session ${sessionId}. Falling back to default.`); - candidate.metadata = { - permissionMode: 'acceptEdits', - cwd: candidate.metadata?.cwd, - }; - } - if (candidate.metadata?.cwd && !(URI.isUri(candidate.metadata.cwd))) { - this._logService.warn(`Invalid cwd "${candidate.metadata.cwd}" found in metadata for session ${sessionId}. Ignoring.`); - candidate.metadata = { - permissionMode: candidate.metadata.permissionMode, - cwd: undefined, - }; - } - return { - permissionMode: candidate.metadata?.permissionMode, - cwd: candidate.metadata?.cwd, - }; + const sessionResource = ClaudeSessionUri.forSessionId(sessionId); + const session = await this._claudeCodeSessionService.getSession(sessionResource, CancellationToken.None); + if (session?.cwd) { + return URI.file(session.cwd); } + + return this._optionBuilder.getDefaultFolder(); } + // #endregion + updateItemLabel(sessionId: string, label: string): void { const resource = ClaudeSessionUri.forSessionId(sessionId); const item = this._controller.items.get(resource); @@ -575,11 +523,6 @@ export class ClaudeChatSessionItemController extends Disposable { lastRequestEnded: session.lastRequestEnded, }; item.iconPath = new vscode.ThemeIcon('claude'); - item.metadata = { - // Allow it to be set when opened - permissionMode: undefined, - cwd: session.cwd ? URI.file(session.cwd) : undefined - }; return item; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts index 07df84031f5ff..4f579a5937234 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeSessionOptionBuilder.ts @@ -42,7 +42,7 @@ export class ClaudeSessionOptionBuilder { private readonly _workspaceService: IWorkspaceService, ) { } - async buildNewSessionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise { + async buildNewSessionGroups(previousInputState?: vscode.ChatSessionInputState): Promise { const groups: vscode.ChatSessionProviderOptionGroup[] = []; const folderGroup = await this.buildNewFolderGroup(previousInputState); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index aaaa49bf69134..4e15302e7612f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -379,16 +379,22 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements changes.push(...(await this.copilotCLIWorktreeManagerService.getWorktreeChanges(sessionId) ?? [])); } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { const workspaceChanges = await this._workspaceFolderService.getWorkspaceChanges(sessionId) ?? []; - changes.push(...workspaceChanges.map(change => new vscode.ChatSessionChangedFile( - vscode.Uri.file(change.filePath), - change.originalFilePath - ? toGitUri(vscode.Uri.file(change.originalFilePath), 'HEAD') - : undefined, - change.modifiedFilePath - ? toGitUri(vscode.Uri.file(change.modifiedFilePath), '') - : undefined, - change.statistics.additions, - change.statistics.deletions))); + const repositoryProperties = await this._metadataStore.getRepositoryProperties(sessionId); + + changes.push(...workspaceChanges.map(change => { + const originalRef = repositoryProperties?.mergeBaseCommit ?? repositoryProperties?.baseCommit ?? 'HEAD'; + + return new vscode.ChatSessionChangedFile( + vscode.Uri.file(change.filePath), + change.originalFilePath + ? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef) + : undefined, + change.modifiedFilePath + ? vscode.Uri.file(change.modifiedFilePath) + : undefined, + change.statistics.additions, + change.statistics.deletions); + })); } return changes; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 6e2196ff1189f..2221cd64f3a97 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -279,16 +279,22 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { // Workspace const workspaceChanges = await this.workspaceFolderService.getWorkspaceChanges(session.id) ?? []; - changes.push(...workspaceChanges.map(change => new vscode.ChatSessionChangedFile( - vscode.Uri.file(change.filePath), - change.originalFilePath - ? toGitUri(vscode.Uri.file(change.originalFilePath), 'HEAD') - : undefined, - change.modifiedFilePath - ? vscode.Uri.file(change.modifiedFilePath) - : undefined, - change.statistics.additions, - change.statistics.deletions))); + const repositoryProperties = await this.chatSessionMetadataStore.getRepositoryProperties(session.id); + + changes.push(...workspaceChanges.map(change => { + const originalRef = repositoryProperties?.mergeBaseCommit ?? 'HEAD'; + + return new vscode.ChatSessionChangedFile( + vscode.Uri.file(change.filePath), + change.originalFilePath + ? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef) + : undefined, + change.modifiedFilePath + ? vscode.Uri.file(change.modifiedFilePath) + : undefined, + change.statistics.additions, + change.statistics.deletions); + })); } // Status diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index d4596a106c8d2..6c1e857cf1c8e 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -254,6 +254,10 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol ? await this.gitService.getBranchBase(repoContext.rootUri, repoContext.headBranchName) : undefined; + const mergeBaseCommit = repoContext?.headBranchName && branchBase?.commit + ? await this.gitService.getMergeBase(repoContext.rootUri, repoContext.headBranchName, branchBase.commit) + : undefined; + const gitHubRemote = repoContext ? getGitHubRepoInfoFromContext(repoContext) : undefined; @@ -275,6 +279,8 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol upstreamBranchName: repoContext?.upstreamRemote && repoContext?.upstreamBranchName ? `${repoContext.upstreamRemote}/${repoContext.upstreamBranchName}` : undefined, + baseCommit: repoContext.headCommitHash, + mergeBaseCommit, hasGitHubRemote: gitHubRemote !== undefined, incomingChanges, outgoingChanges, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index d539bbb7545f8..180746f260cb7 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'path'; import type * as vscode from 'vscode'; // eslint-disable-next-line no-duplicate-imports import * as vscodeShim from 'vscode'; @@ -139,19 +140,68 @@ function createTestRequest(prompt: string): TestChatRequest { } /** - * Adds a session item to the controller's items map so that metadata methods work. + * Adds a session item to the controller's items map. * This simulates what newChatSessionItemHandler does when VS Code creates a new session. */ -function seedSessionItem(sessionId: string, metadata?: Record): void { +function seedSessionItem(sessionId: string): void { const resource = ClaudeSessionUri.forSessionId(sessionId); const item: vscode.ChatSessionItem = { resource, label: sessionId, - metadata, }; lastCreatedItemsMap.set(resource.toString(), item); } +/** + * Builds a minimal permission mode input state group with the given mode selected. + * Defaults to 'acceptEdits' if no mode specified. + */ +function buildPermissionModeGroup(selectedMode: string = 'acceptEdits'): vscode.ChatSessionProviderOptionGroup { + const items = [ + { id: 'default', name: 'Ask before edits' }, + { id: 'acceptEdits', name: 'Edit automatically' }, + { id: 'plan', name: 'Plan mode' }, + { id: 'bypassPermissions', name: 'Yolo mode' }, + { id: 'dontAsk', name: 'Don\'t ask' }, + ]; + const selected = items.find(i => i.id === selectedMode) ?? items[1]; + return { + id: 'permissionMode', + name: 'Permission Mode', + items, + selected: { ...selected }, + }; +} + +/** + * Builds a minimal folder input state group with the given folder selected. + */ +function buildFolderGroup(selectedFolderPath: string, allFolderPaths?: string[]): vscode.ChatSessionProviderOptionGroup { + const paths = allFolderPaths ?? [selectedFolderPath]; + const items = paths.map(p => ({ id: p, name: path.basename(p) })); + const selected = items.find(i => i.id === selectedFolderPath) ?? items[0]; + return { + id: 'folder', + name: 'Folder', + items, + selected: { ...selected }, + }; +} + +/** + * Builds inputState groups for test chat contexts. + * Always includes a permission mode group. Folder group is added when folderPath is provided. + */ +function buildInputStateGroups(options?: { permissionMode?: string; folderPath?: string; allFolderPaths?: string[] }): vscode.ChatSessionProviderOptionGroup[] { + const groups: vscode.ChatSessionProviderOptionGroup[] = [ + buildPermissionModeGroup(options?.permissionMode), + ]; + if (options?.folderPath) { + groups.push(buildFolderGroup(options.folderPath, options.allFolderPaths)); + } + return groups; +} + function createProviderWithServices( store: DisposableStore, workspaceFolders: URI[], @@ -220,6 +270,7 @@ async function runHandlerAndCapture( testAccessor: ITestingServicesAccessor, sessionId: string, sessionService: IClaudeCodeSessionService, + options?: { permissionMode?: string; folderPath?: string; allFolderPaths?: string[] }, ): Promise<{ permissionMode: string; folderInfo: ClaudeFolderInfo }> { vi.mocked(sessionService.getSession).mockResolvedValue(undefined); if (!lastCreatedItemsMap.has(ClaudeSessionUri.forSessionId(sessionId).toString())) { @@ -231,6 +282,7 @@ async function runHandlerAndCapture( const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession'); const handler = contentProvider.createHandler(); + const groups = buildInputStateGroups(options); const context: vscode.ChatContext = { history: [], yieldRequested: false, @@ -240,7 +292,7 @@ async function runHandlerAndCapture( resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups, sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; @@ -407,15 +459,13 @@ describe('ChatSessionContentProvider', () => { expect(folderInfo.additionalDirectories).toEqual([folderB.fsPath, folderC.fsPath]); }); - it('uses selected folder as cwd after metadata update', async () => { + it('uses selected folder from inputState as cwd', async () => { seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, cwd: folderB }; - } - const { folderInfo } = await runHandlerAndCapture(multiRootProvider, multiRootAccessor, 'test-session', mockSessionService); + const { folderInfo } = await runHandlerAndCapture(multiRootProvider, multiRootAccessor, 'test-session', mockSessionService, { + folderPath: folderB.fsPath, + allFolderPaths: [folderA.fsPath, folderB.fsPath, folderC.fsPath], + }); expect(folderInfo.cwd).toBe(folderB.fsPath); expect(folderInfo.additionalDirectories).toEqual([folderA.fsPath, folderC.fsPath]); }); @@ -455,13 +505,12 @@ describe('ChatSessionContentProvider', () => { }); it('locked folder option preserves the selected folder, not the first one', async () => { - // Simulate user selecting folder B before the session is created - seedSessionItem('pre-created-session'); - const sessionUri = createClaudeSessionUri('pre-created-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, cwd: folderB }; - } + // Set folderB as the session's folder via sessionStateService + const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService); + sessionStateService.setFolderInfoForSession('pre-created-session', { + cwd: folderB.fsPath, + additionalDirectories: [folderA.fsPath], + }); // Now load the same session as an existing session const session = { @@ -474,6 +523,7 @@ describe('ChatSessionContentProvider', () => { }; vi.mocked(mockSessionService.getSession).mockResolvedValue(session as any); + const sessionUri = createClaudeSessionUri('pre-created-session'); const state = await getInputState(sessionUri); const folderGroup = getGroup(state, 'folder'); expect(folderGroup).toBeDefined(); @@ -548,102 +598,16 @@ describe('ChatSessionContentProvider', () => { ]); seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, cwd: selectedFolder }; - } - const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService); + const { folderInfo } = await runHandlerAndCapture(emptyWorkspaceProvider, emptyAccessor, 'test-session', mockSessionService, { + folderPath: selectedFolder.fsPath, + }); expect(folderInfo.cwd).toBe(selectedFolder.fsPath); }); }); // #endregion - // #region Permission Mode Metadata - - describe('permission mode stored in metadata', () => { - it('metadata change does not push to session state service until handler runs', async () => { - seedSessionItem('test-session'); - const mockSessionStateService = accessor.get(IClaudeSessionStateService); - const setPermissionSpy = vi.spyOn(mockSessionStateService, 'setPermissionModeForSession'); - - // Simulate what _handleInputStateChange does: store in metadata - const sessionUri = createClaudeSessionUri('test-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, permissionMode: 'plan' }; - } - - // Session state service should NOT have been called yet - expect(setPermissionSpy).not.toHaveBeenCalled(); - - // But once the handler runs, it should commit the metadata value - const { permissionMode } = await runHandlerAndCapture(provider, accessor, 'test-session', mockSessionService); - expect(permissionMode).toBe('plan'); - }); - - it('handler commits local permission mode from metadata', async () => { - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, permissionMode: 'plan' }; - } - - const { permissionMode } = await runHandlerAndCapture(provider, accessor, 'test-session', mockSessionService); - expect(permissionMode).toBe('plan'); - }); - - it('local permission mode takes priority over session state service', async () => { - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - - // Set a value in the session state service directly - const mockSessionStateService = accessor.get(IClaudeSessionStateService); - mockSessionStateService.setPermissionModeForSession('test-session', 'acceptEdits'); - - // Now set a different local selection via metadata - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, permissionMode: 'plan' }; - } - - // Local selection should take priority - const { permissionMode } = await runHandlerAndCapture(provider, accessor, 'test-session', mockSessionService); - expect(permissionMode).toBe('plan'); - }); - - it('falls back to acceptEdits for invalid permission mode in metadata', async () => { - seedSessionItem('test-session'); - const sessionUri = createClaudeSessionUri('test-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, permissionMode: 'not-a-real-mode' }; - } - - const { permissionMode } = await runHandlerAndCapture(provider, accessor, 'test-session', mockSessionService); - expect(permissionMode).not.toBe('not-a-real-mode'); - }); - - it('accepts all valid permission modes in metadata', async () => { - const validModes = ['default', 'acceptEdits', 'bypassPermissions', 'plan', 'dontAsk'] as const; - - for (const mode of validModes) { - seedSessionItem(`test-session-${mode}`); - const sessionUri = createClaudeSessionUri(`test-session-${mode}`); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, permissionMode: mode }; - } - - const { permissionMode } = await runHandlerAndCapture(provider, accessor, `test-session-${mode}`, mockSessionService); - expect(permissionMode).toBe(mode); - } - }); - }); - // #endregion // #region Initial Session Options @@ -653,7 +617,7 @@ describe('ChatSessionContentProvider', () => { let handlerProvider: ClaudeChatSessionContentProvider; let handlerAccessor: ITestingServicesAccessor; - function createChatContext(sessionId: string): vscode.ChatContext { + function createChatContext(sessionId: string, options?: { permissionMode?: string }): vscode.ChatContext { return { history: [], yieldRequested: false, @@ -663,7 +627,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups: buildInputStateGroups(options), sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; } @@ -680,16 +644,16 @@ describe('ChatSessionContentProvider', () => { handlerAccessor = result.accessor; }); - it('sets permission mode from item metadata on new session', async () => { + it('sets permission mode from inputState on new session', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); - seedSessionItem('new-session-1', { permissionMode: 'plan' }); + seedSessionItem('new-session-1'); const sessionStateService = handlerAccessor.get(IClaudeSessionStateService); const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession'); const handler = handlerProvider.createHandler(); - const context = createChatContext('new-session-1'); + const context = createChatContext('new-session-1', { permissionMode: 'plan' }); const stream = new MockChatResponseStream(); await handler(createTestRequest('hello'), context, stream, CancellationToken.None); @@ -697,7 +661,7 @@ describe('ChatSessionContentProvider', () => { expect(setPermissionSpy).toHaveBeenCalledWith('new-session-1', 'plan'); }); - it('falls back to session state service when item metadata has no permission mode', async () => { + it('defaults to acceptEdits when inputState has default permission mode', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); seedSessionItem('new-session-2'); @@ -714,21 +678,16 @@ describe('ChatSessionContentProvider', () => { expect(setPermissionSpy).toHaveBeenCalledWith('new-session-2', 'acceptEdits'); }); - it('does not overwrite permission mode if already set for the session', async () => { + it('commits the inputState permission mode to session state service', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); seedSessionItem('pre-set-session'); - const sessionUri = createClaudeSessionUri('pre-set-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, permissionMode: 'default' }; - } const sessionStateService = handlerAccessor.get(IClaudeSessionStateService); const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession'); const handler = handlerProvider.createHandler(); - const context = createChatContext('pre-set-session'); + const context = createChatContext('pre-set-session', { permissionMode: 'default' }); const stream = new MockChatResponseStream(); await handler(createTestRequest('hello'), context, stream, CancellationToken.None); @@ -736,14 +695,14 @@ describe('ChatSessionContentProvider', () => { expect(setPermissionSpy).toHaveBeenCalledWith('pre-set-session', 'default'); }); - it('does not apply initialSessionOptions on resumed sessions', async () => { + it('commits inputState permission mode on resumed sessions', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue({ id: 'existing-session', messages: [{ type: 'user', message: { role: 'user', content: 'Hello' } }], subagents: [], } as any); - seedSessionItem('existing-session', { permissionMode: 'acceptEdits' }); + seedSessionItem('existing-session'); const sessionStateService = handlerAccessor.get(IClaudeSessionStateService); const setPermissionSpy = vi.spyOn(sessionStateService, 'setPermissionModeForSession'); @@ -755,7 +714,7 @@ describe('ChatSessionContentProvider', () => { await handler(createTestRequest('hello'), context, stream, CancellationToken.None); const committedMode = setPermissionSpy.mock.calls.find(c => c[0] === 'existing-session')?.[1]; - expect(committedMode).not.toBe('bypassPermissions'); + expect(committedMode).toBe('acceptEdits'); }); }); @@ -766,7 +725,7 @@ describe('ChatSessionContentProvider', () => { let multiRootProvider: ClaudeChatSessionContentProvider; let multiRootAccessor: ITestingServicesAccessor; - function createChatContext(sessionId: string): vscode.ChatContext { + function createChatContext(sessionId: string, options?: { folderPath?: string }): vscode.ChatContext { return { history: [], yieldRequested: false, @@ -776,7 +735,14 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, + inputState: { + groups: buildInputStateGroups({ + folderPath: options?.folderPath, + allFolderPaths: [folderA.fsPath, folderB.fsPath], + }), + sessionResource: undefined, + onDidChange: Event.None, + }, }, } as vscode.ChatContext; } @@ -791,16 +757,16 @@ describe('ChatSessionContentProvider', () => { multiRootAccessor = result.accessor; }); - it('sets folder from item metadata on new session', async () => { + it('sets folder from inputState on new session', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); - seedSessionItem('new-folder-session', { cwd: folderB }); + seedSessionItem('new-folder-session'); const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService); const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession'); const handler = multiRootProvider.createHandler(); - const context = createChatContext('new-folder-session'); + const context = createChatContext('new-folder-session', { folderPath: folderB.fsPath }); const stream = new MockChatResponseStream(); await handler(createTestRequest('hello'), context, stream, CancellationToken.None); @@ -809,21 +775,16 @@ describe('ChatSessionContentProvider', () => { expect(folderInfo?.cwd).toBe(folderB.fsPath); }); - it('does not overwrite folder if already set for the session', async () => { + it('commits inputState folder selection to session state service', async () => { vi.mocked(mockSessionService.getSession).mockResolvedValue(undefined); seedSessionItem('pre-folder-session'); - const sessionUri = createClaudeSessionUri('pre-folder-session'); - const item = lastCreatedItemsMap.get(sessionUri.toString()); - if (item) { - item.metadata = { ...item.metadata, cwd: folderA }; - } const sessionStateService = multiRootAccessor.get(IClaudeSessionStateService); const setFolderInfoSpy = vi.spyOn(sessionStateService, 'setFolderInfoForSession'); const handler = multiRootProvider.createHandler(); - const context = createChatContext('pre-folder-session'); + const context = createChatContext('pre-folder-session', { folderPath: folderA.fsPath }); const stream = new MockChatResponseStream(); await handler(createTestRequest('hello'), context, stream, CancellationToken.None); @@ -851,7 +812,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; } @@ -951,7 +912,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, + inputState: { groups: buildInputStateGroups(), sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; } @@ -1496,122 +1457,6 @@ describe('ClaudeChatSessionItemController', () => { // #endregion - // #region Metadata management - - describe('getMetadata and setMetadata', () => { - beforeEach(() => { - controller = createController([URI.file('/project')]); - }); - - it('returns undefined when no item exists for the session', () => { - const result = controller.getMetadata('nonexistent-session'); - expect(result).toBeUndefined(); - }); - - it('returns undefined permission mode when item has no metadata set', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - const meta = controller.getMetadata('session-1'); - expect(meta).toBeDefined(); - expect(meta!.permissionMode).toBeUndefined(); - }); - - it('stores and retrieves permission mode', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - controller.setMetadata('session-1', { permissionMode: 'plan' }); - - const meta = controller.getMetadata('session-1'); - expect(meta!.permissionMode).toBe('plan'); - }); - - it('stores and retrieves cwd', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - const folderUri = URI.file('/some/folder'); - controller.setMetadata('session-1', { cwd: folderUri }); - - const meta = controller.getMetadata('session-1'); - expect(meta!.cwd).toBeDefined(); - expect(meta!.cwd!.fsPath).toBe(folderUri.fsPath); - }); - - it('preserves existing permission mode when only cwd is updated', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - controller.setMetadata('session-1', { permissionMode: 'plan' }); - controller.setMetadata('session-1', { cwd: URI.file('/folder') }); - - const meta = controller.getMetadata('session-1'); - expect(meta!.permissionMode).toBe('plan'); - expect(meta!.cwd!.fsPath).toBe(URI.file('/folder').fsPath); - }); - - it('preserves existing cwd when only permission mode is updated', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - const folderUri = URI.file('/some/folder'); - controller.setMetadata('session-1', { cwd: folderUri }); - controller.setMetadata('session-1', { permissionMode: 'default' }); - - const meta = controller.getMetadata('session-1'); - expect(meta!.permissionMode).toBe('default'); - expect(meta!.cwd!.fsPath).toBe(folderUri.fsPath); - }); - - it('falls back to acceptEdits for invalid permission mode in metadata', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - // Directly set invalid metadata to simulate corrupted state - const item = getItem('session-1'); - item!.metadata = { permissionMode: 'garbage' }; - - const meta = controller.getMetadata('session-1'); - expect(meta!.permissionMode).toBe('acceptEdits'); - }); - - it('falls back to acceptEdits for empty string permission mode in metadata', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - const item = getItem('session-1'); - item!.metadata = { permissionMode: '' }; - - const meta = controller.getMetadata('session-1'); - expect(meta!.permissionMode).toBe('acceptEdits'); - }); - - it('preserves unknown metadata fields when setting known fields', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - const item = getItem('session-1'); - item!.metadata = { permissionMode: 'plan', customField: 'should-survive' }; - - controller.setMetadata('session-1', { cwd: URI.file('/new-cwd') }); - - expect(item!.metadata.customField).toBe('should-survive'); - expect(item!.metadata.permissionMode).toBe('plan'); - expect(URI.isUri(item!.metadata.cwd)).toBe(true); - }); - - it('clears invalid cwd in metadata', async () => { - await controller.updateItemStatus('session-1', ChatSessionStatus.InProgress, 'hello'); - - // Directly set invalid cwd metadata - const item = getItem('session-1'); - item!.metadata = { permissionMode: 'plan', cwd: 'not-a-uri' }; - - const meta = controller.getMetadata('session-1'); - expect(meta!.permissionMode).toBe('plan'); - expect(meta!.cwd).toBeUndefined(); - }); - - it('does nothing when setting metadata on a nonexistent session', () => { - // Should not throw - controller.setMetadata('nonexistent', { permissionMode: 'plan' }); - expect(controller.getMetadata('nonexistent')).toBeUndefined(); - }); - }); - // #endregion // #region forkHandler @@ -1643,7 +1488,6 @@ describe('ClaudeChatSessionItemController', () => { lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original', - metadata: { permissionMode: 'plan', cwd: URI.file('/project') }, }); const result = await lastForkHandler!(sessionResource, undefined, CancellationToken.None); @@ -1653,20 +1497,19 @@ describe('ClaudeChatSessionItemController', () => { expect(result.label).toContain('Forked'); }); - it('clones metadata from original item to forked item', async () => { + it('copies session state from parent to forked session', async () => { const sessionResource = ClaudeSessionUri.forSessionId('sess-1'); - const originalMetadata = { permissionMode: 'plan', cwd: URI.file('/project'), customField: 'preserved' }; lastCreatedItemsMap.set(sessionResource.toString(), { resource: sessionResource, label: 'Original', - metadata: originalMetadata, }); const result = await lastForkHandler!(sessionResource, undefined, CancellationToken.None); - // Metadata should be a clone, not the same reference - expect(result.metadata).toEqual(originalMetadata); - expect(result.metadata).not.toBe(originalMetadata); + // The forked item should be properly structured + expect(result.resource.toString()).toContain('forked-session-id'); + expect(result.iconPath).toBeDefined(); + expect(result.timing).toBeDefined(); }); it('forks at the message before the specified request', async () => { diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 6fc9b72573250..720617e5ca841 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -45,7 +45,7 @@ import { IDefaultIntentRequestHandlerOptions } from '../../prompt/node/defaultIn import { IDocumentContext } from '../../prompt/node/documentContext'; import { IBuildPromptResult, IIntent, IIntentInvocation } from '../../prompt/node/intents'; import { AgentPrompt, AgentPromptProps } from '../../prompts/node/agent/agentPrompt'; -import { BackgroundSummarizationState, BackgroundSummarizer, IBackgroundSummarizationResult } from '../../prompts/node/agent/backgroundSummarizer'; +import { BackgroundSummarizationState, BackgroundSummarizer, IBackgroundSummarizationResult, shouldKickOffBackgroundSummarization } from '../../prompts/node/agent/backgroundSummarizer'; import { AgentPromptCustomizations, PromptRegistry } from '../../prompts/node/agent/promptRegistry'; import { extractInlineSummary, InlineSummarizationUserMessage, SummarizedConversationHistory, SummarizedConversationHistoryMetadata, SummarizedConversationHistoryPropsBuilder } from '../../prompts/node/agent/summarizedConversationHistory'; import { PromptRenderer, renderPromptElement } from '../../prompts/node/base/promptRenderer'; @@ -360,6 +360,12 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I /** Cached model capabilities from the most recent main agent render, reused by the background summarizer. */ private _lastModelCapabilities: { enableThinking: boolean; reasoningEffort: string | undefined; enableToolSearch: boolean; enableContextEditing: boolean } | undefined; + /** + * RNG used to jitter the inline-summarization trigger threshold around 0.80. + * Tests may overwrite this directly (e.g. `(invocation as any)._thresholdRng = () => 0.5`). + */ + private _thresholdRng: () => number = Math.random; + constructor( intent: IIntent, location: ChatLocation, @@ -660,13 +666,26 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I )); } - // Post-render: kick off background compaction at ≥ 80% if idle. + // Post-render: kick off background compaction if idle and over the + // threshold. For the inline-summarization path we care about prompt + // cache parity with the main agent fetch — so we gate kick-off on a + // completed tool call (cache has been warmed) and jitter the threshold + // around 0.80 to avoid firing at the same exact boundary every time. + // The non-inline path forks its own prompt and sees no cache benefit, + // so it keeps the simple >= 0.80 behavior. if (summarizationEnabled && backgroundSummarizer && !didSummarizeThisIteration) { const postRenderRatio = baseBudget > 0 ? (result.tokenCount + toolTokens) / baseBudget : 0; - if (postRenderRatio >= 0.80 && (backgroundSummarizer.state === BackgroundSummarizationState.Idle || backgroundSummarizer.state === BackgroundSummarizationState.Failed)) { + const idleOrFailed = backgroundSummarizer.state === BackgroundSummarizationState.Idle + || backgroundSummarizer.state === BackgroundSummarizationState.Failed; + + const cacheWarm = (promptContext.toolCallRounds?.length ?? 0) > 0; + + const kickOff = shouldKickOffBackgroundSummarization(postRenderRatio, useInlineSummarization, cacheWarm, this._thresholdRng); + + if (kickOff && idleOrFailed) { if (useInlineSummarization) { // Compute and cache model capabilities from the current render's // messages. These must match the main agent fetch for cache parity. diff --git a/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts b/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts index 66492043a55ea..ec4d171d5abe1 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/backgroundSummarizer.ts @@ -39,6 +39,60 @@ export interface IBackgroundSummarizationResult { readonly numRoundsSinceLastSummarization?: number; } +/** + * Thresholds used by {@link shouldKickOffBackgroundSummarization}. Exported so + * tests can reference the same numbers without repeating them. + */ +export const BackgroundSummarizationThresholds = { + /** Trigger ratio for the non-inline path (no prompt-cache benefit). */ + base: 0.80, + /** Minimum of the jittered warm-cache range for the inline path. */ + warmJitterMin: 0.78, + /** Width of the jittered warm-cache range; together with `warmJitterMin` yields [0.78, 0.82). */ + warmJitterSpan: 0.04, + /** + * Cold-cache emergency ratio for the inline path. Above this we kick off + * even without a warmed cache to avoid forcing a foreground sync compaction + * on the next render. Tuned low enough that long-running sessions stay + * ahead of the budget without relying on foreground compaction. + */ + emergency: 0.90, +} as const; + +/** + * Decide whether to kick off post-render background compaction. + * + * For the inline-summarization path prompt-cache parity matters, so we: + * - require a completed tool call in this turn ("warm" cache) before + * firing at the normal, jittered ~0.80 threshold; + * - allow an emergency kick-off at >= 0.90 even with a cold cache to + * avoid forcing a foreground sync compaction on the next render. + * + * The jitter range straddles the historical 0.80 threshold (not "lower the + * bar") — the goal is to avoid always firing at the exact same boundary, + * not to kick off systematically earlier. + * + * The non-inline path forks its own prompt (no cache benefit) and keeps the + * simple >= 0.80 behavior. `rng` is only consumed on the warm-cache inline + * branch, which keeps deterministic tests straightforward. + */ +export function shouldKickOffBackgroundSummarization( + postRenderRatio: number, + useInlineSummarization: boolean, + cacheWarm: boolean, + rng: () => number, +): boolean { + const t = BackgroundSummarizationThresholds; + if (!useInlineSummarization) { + return postRenderRatio >= t.base; + } + if (!cacheWarm) { + return postRenderRatio >= t.emergency; + } + const jittered = t.warmJitterMin + rng() * t.warmJitterSpan; + return postRenderRatio >= jittered; +} + /** * Tracks a single background summarization pass for one chat session. * diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts index 300282b6f098e..bc55ad7500f71 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { describe, expect, test } from 'vitest'; -import { BackgroundSummarizationState, BackgroundSummarizer, IBackgroundSummarizationResult } from '../backgroundSummarizer'; +import { BackgroundSummarizationState, BackgroundSummarizationThresholds, BackgroundSummarizer, IBackgroundSummarizationResult, shouldKickOffBackgroundSummarization } from '../backgroundSummarizer'; describe('BackgroundSummarizer', () => { @@ -253,3 +253,66 @@ describe('BackgroundSummarizer', () => { expect(summarizer.state).toBe(BackgroundSummarizationState.Idle); }); }); + +const { base, warmJitterMin, warmJitterSpan, emergency } = BackgroundSummarizationThresholds; + +// rng that always returns 0.5 -> threshold sits exactly at the center of the +// jitter range. With [0.78, 0.82) that's 0.80. +const midRng = () => 0.5; +// rng that forces the maximum of the jitter range. +const maxRng = () => 1 - Number.EPSILON; +// rng that should never be called on cold/non-inline branches. +const unusedRng = () => { + throw new Error('rng should not be consumed'); +}; + +describe('shouldKickOffBackgroundSummarization', () => { + describe('inline + cold cache', () => { + test('defers kick-off below the emergency threshold', () => { + // Cold turn sitting in the old 0.80 trigger band — must not fire. + expect(shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).toBe(false); + }); + + test('kicks off at the emergency threshold', () => { + expect(shouldKickOffBackgroundSummarization(emergency, true, false, unusedRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).toBe(true); + }); + + test('does not consume the rng on the cold branch', () => { + // The unusedRng would throw if consumed — asserting no throw is the check. + expect(() => shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).not.toThrow(); + expect(() => shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).not.toThrow(); + }); + }); + + describe('inline + warm cache', () => { + test('kicks off at the jittered midpoint (0.80) when ratio meets it', () => { + expect(shouldKickOffBackgroundSummarization(0.80, true, true, midRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(0.81, true, true, midRng)).toBe(true); + }); + + test('defers when ratio is under the jittered threshold', () => { + // midRng -> 0.80; 0.77 is below the entire jitter window. + expect(shouldKickOffBackgroundSummarization(0.77, true, true, midRng)).toBe(false); + // Also below the minimum of the window regardless of rng. + expect(shouldKickOffBackgroundSummarization(warmJitterMin - 0.0001, true, true, () => 0)).toBe(false); + }); + + test('respects the top of the jitter range', () => { + // With maxRng, threshold approaches warmJitterMin + warmJitterSpan = 0.82. + // 0.81 lands below it, so we defer. + expect(shouldKickOffBackgroundSummarization(0.81, true, true, maxRng)).toBe(false); + // 0.82 meets it. + expect(shouldKickOffBackgroundSummarization(warmJitterMin + warmJitterSpan, true, true, maxRng)).toBe(true); + }); + }); + + describe('non-inline path', () => { + test('uses the fixed base threshold and ignores cache warmth', () => { + // Warm, cold — both behave the same on non-inline. + expect(shouldKickOffBackgroundSummarization(base, false, false, unusedRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(base, false, true, unusedRng)).toBe(true); + expect(shouldKickOffBackgroundSummarization(base - 0.0001, false, true, unusedRng)).toBe(false); + }); + }); +}); diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.cdp.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.cdp.test.ts new file mode 100644 index 0000000000000..deab20e2a1993 --- /dev/null +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.cdp.test.ts @@ -0,0 +1,297 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { window, workspace } from 'vscode'; +import { assertNoRpc, closeAllEditors } from '../utils'; + +/** + * We only care about target-lifecycle and browser-level events. + */ +const CAPTURED_DOMAINS = ['Browser', 'Target']; + +(vscode.env.uiKind === vscode.UIKind.Web ? suite.skip : suite)('vscode API - browser CDP', () => { + + teardown(async function () { + assertNoRpc(); + await closeAllEditors(); + }); + + // #region Helpers + + type CdpLog = { direction: 'send' | 'recv' | 'resp'; method?: string; params?: any; result?: any; sessionId?: string }; + + /** + * Creates a CDP harness that records all Browser.* / Target.* traffic. + */ + function createHarness(session: vscode.BrowserCDPSession) { + const log: CdpLog[] = []; + let nextId = 1; + const pending = new Map void; reject: (e: Error) => void; method: string }>(); + + session.onDidReceiveMessage((msg: any) => { + // Record events (no id) whose method is in a captured domain + if (!msg.id && msg.method) { + const domain = msg.method.split('.')[0]; + if (CAPTURED_DOMAINS.includes(domain)) { + log.push({ direction: 'recv', method: msg.method, params: msg.params, sessionId: msg.sessionId }); + } + } + // Resolve pending requests + if (msg.id && pending.has(msg.id)) { + const entry = pending.get(msg.id)!; + pending.delete(msg.id); + if (msg.error) { + entry.reject(new Error(`CDP error ${msg.error.code}: ${msg.error.message}`)); + } else { + const domain = entry.method.split('.')[0]; + if (CAPTURED_DOMAINS.includes(domain)) { + log.push({ direction: 'resp', method: entry.method, result: msg.result }); + } + entry.resolve(msg.result); + } + } + }); + + async function cdpSend(method: string, params: object = {}, sessionId?: string): Promise { + const id = nextId++; + // Record the outgoing command + const domain = method.split('.')[0]; + if (CAPTURED_DOMAINS.includes(domain)) { + log.push({ direction: 'send', method, params: Object.keys(params).length ? params : undefined, sessionId }); + } + + const response = new Promise((resolve, reject) => { + pending.set(id, { resolve, reject, method }); + }); + await session.sendMessage({ id, method, params, sessionId }); + return response; + } + + function waitForEvent(predicate: (msg: any) => boolean, timeoutMs = 15_000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + disposable.dispose(); + reject(new Error('Timed out waiting for CDP event')); + }, timeoutMs); + const disposable = session.onDidReceiveMessage((msg: any) => { + if (predicate(msg)) { + clearTimeout(timeout); + disposable.dispose(); + resolve(msg); + } + }); + }); + } + + return { log, cdpSend, waitForEvent }; + } + + /** + * Normalize a log for snapshot comparison. Replaces volatile IDs with + * stable placeholders and strips file-system-specific paths. + */ + function normalizeLog(log: CdpLog[], workspaceRoot: string): CdpLog[] { + const targetIds: { [original: string]: string } = {}; + const idCounters: { [domain: string]: number } = {}; + + function replaceId(domain: string, id: string): string { + const key = `${domain}-${id}`; + if (!targetIds[key]) { + if (!idCounters[domain]) { + idCounters[domain] = 0; + } + targetIds[key] = `<${domain}-${idCounters[domain]++}>`; + } + return targetIds[key]; + } + + // Deep-clone and normalize + const normalized = JSON.parse(JSON.stringify(log)); + + function normalizeValue(obj: any): any { + if (obj === null || obj === undefined) { + return obj; + } + if (typeof obj === 'string') { + // Replace file:// URIs with /basename (must run before workspace root replacement) + let result = obj.replace(/file:\/\/\/[^\s"')}\]]+/g, (match) => { + const basename = decodeURIComponent(match.split('/').pop() || match); + return `/${basename}`; + }); + // Replace workspace root in remaining paths (handles both raw and URI-encoded forms) + const normalizedRoot = workspaceRoot.replace(/\\/g, '/'); + const encodedRoot = encodeURI(normalizedRoot).replace(/%5C/gi, '/'); + result = result.split(encodedRoot).join(''); + result = result.split(normalizedRoot).join(''); + result = result.split(workspaceRoot).join(''); + return result; + } + if (Array.isArray(obj)) { + return obj.map(normalizeValue); + } + if (typeof obj === 'object') { + const out: any = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === 'targetId' || key === 'openerId') { + out[key] = replaceId('target', value as string); + } else if (key === 'sessionId') { + out[key] = replaceId('session', value as string); + } else if (key === 'browserContextId') { + out[key] = replaceId('context', value as string); + } else if (key === 'title' && obj['type'] === 'browser') { + out[key] = ''; + } else if ((key === 'title' || key === 'url') && (value === '' || value === 'about:blank')) { + // On Linux, '' can show up instead of 'about:blank'. So normalize both to ''. + out[key] = ''; + } else if (key === 'ts') { + // Skip timestamps + continue; + } else { + out[key] = normalizeValue(value); + } + } + return out; + } + return obj; + } + + for (const entry of normalized) { + if (entry.sessionId) { + entry.sessionId = replaceId('session', entry.sessionId); + } + if (entry.params) { + entry.params = normalizeValue(entry.params); + } + if (entry.result) { + entry.result = normalizeValue(entry.result); + } + } + + return normalized; + } + + // #endregion + + test('CDP debugging golden scenario', async function () { + this.timeout(30_000); + + const workspaceRoot = workspace.rootPath!; + assert.ok(workspaceRoot, 'workspace root must be available'); + + const pageUrl = vscode.Uri.file(path.join(workspaceRoot, 'index.html')).toString(); + + // 1. Open a browser tab with an empty URL + const tab = await window.openBrowserTab(''); + const session = await tab.startCDPSession(); + const { log, cdpSend, waitForEvent } = createHarness(session); + + // 2. Attach to the browser target (mirrors BrowserTargetManager.connect) + const browserAttach = await cdpSend('Target.attachToBrowserTarget'); + assert.ok(browserAttach.sessionId, 'should get a browser session'); + const browserSessionId = browserAttach.sessionId; + + // 3. Discover targets and find the page (mirrors waitForMainTarget) + const pageCreated = waitForEvent( + (msg: any) => msg.method === 'Target.targetCreated' + && msg.params?.targetInfo?.type === 'page', + ); + await cdpSend('Target.setDiscoverTargets', { discover: true }, browserSessionId); + const pageTarget = (await pageCreated).params.targetInfo; + + // 4. Attach to the page target (mirrors attachToTarget in waitForMainTarget) + const pageAttach = await cdpSend('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true, + }, browserSessionId); + assert.ok(pageAttach.sessionId, 'should get a page session'); + const pageSessionId = pageAttach.sessionId; + + // 5. Enable auto-attach on the page session with waitForDebuggerOnStart + // (mirrors BrowserTargetManager.attachedToTarget) + await cdpSend('Target.setAutoAttach', { + autoAttach: true, + waitForDebuggerOnStart: true, + flatten: true, + }, pageSessionId); + + // 6. Set up listener for worker auto-attach before navigating + const workerAttached = waitForEvent( + (msg: any) => + msg.method === 'Target.attachedToTarget' + && msg.params?.targetInfo?.type === 'worker' + && msg.sessionId === pageSessionId, + ); + + // 7. Navigate to the test page (mirrors finishLaunch -> Page.navigate) + await cdpSend('Page.navigate', { url: pageUrl }, pageSessionId); + + // 8. Wait for the worker to be auto-attached + const workerEvent = await workerAttached; + assert.strictEqual(workerEvent.params.targetInfo.type, 'worker'); + assert.strictEqual(workerEvent.params.waitingForDebugger, true); + const workerSessionId = workerEvent.params.sessionId; + + // 9. Resume the worker (mirrors js-debug's runIfWaitingForDebugger) + await cdpSend('Runtime.runIfWaitingForDebugger', {}, workerSessionId); + + // 10. Evaluate a script on the page that sends a message to the worker and gets the echo + const evalResult = await cdpSend('Runtime.evaluate', { + expression: 'sendMessage("hello from test")', + awaitPromise: true, + returnByValue: true, + }, pageSessionId); + assert.strictEqual(evalResult.result.value, 'hello from test'); + + // 11. Close the page target (mirrors closeBrowser -> closeTarget) + const detached = waitForEvent( + (msg: any) => msg.method === 'Target.detachedFromTarget' + && msg.params?.sessionId === pageAttach.sessionId, + ); + const destroyed = waitForEvent( + (msg: any) => msg.method === 'Target.targetDestroyed' + && msg.params?.targetId === pageTarget.targetId, + ); + + await cdpSend('Target.closeTarget', { targetId: pageTarget.targetId }, browserSessionId); + + await detached; + await destroyed; + + // 12. Normalize and compare to snapshot + const actual = normalizeLog(log, workspaceRoot); + + const expected: CdpLog[] = [ + { direction: 'send', method: 'Target.attachToBrowserTarget' }, + { direction: 'recv', method: 'Target.attachedToTarget', params: { sessionId: '', targetInfo: { targetId: '', type: 'browser', title: '', url: '', attached: true, canAccessOpener: false }, waitingForDebugger: false } }, + { direction: 'resp', method: 'Target.attachToBrowserTarget', result: { sessionId: '' } }, + { direction: 'send', method: 'Target.setDiscoverTargets', params: { discover: true }, sessionId: '' }, + { direction: 'recv', method: 'Target.targetCreated', params: { targetInfo: { attached: false, browserContextId: '', canAccessOpener: false, targetId: '', title: '', type: 'page', url: '' } }, sessionId: '' }, + { direction: 'resp', method: 'Target.setDiscoverTargets', result: {} }, + { direction: 'send', method: 'Target.attachToTarget', params: { targetId: '', flatten: true }, sessionId: '' }, + { direction: 'recv', method: 'Target.targetInfoChanged', params: { targetInfo: { attached: false, browserContextId: '', canAccessOpener: false, targetId: '', title: '', type: 'page', url: '' } }, sessionId: '' }, + { direction: 'recv', method: 'Target.attachedToTarget', params: { sessionId: '', targetInfo: { attached: true, browserContextId: '', canAccessOpener: false, targetId: '', title: '', type: 'page', url: '' }, waitingForDebugger: false }, sessionId: '' }, + { direction: 'resp', method: 'Target.attachToTarget', result: { sessionId: '' } }, + { direction: 'send', method: 'Target.setAutoAttach', params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }, sessionId: '' }, + { direction: 'resp', method: 'Target.setAutoAttach', result: {} }, + { direction: 'recv', method: 'Target.targetCreated', params: { targetInfo: { attached: false, browserContextId: '', canAccessOpener: false, targetId: '', title: '/worker.js', type: 'worker', url: '/worker.js' } }, sessionId: '' }, + { direction: 'recv', method: 'Target.targetInfoChanged', params: { targetInfo: { attached: false, browserContextId: '', canAccessOpener: false, targetId: '', title: '/worker.js', type: 'worker', url: '/worker.js' } }, sessionId: '' }, + { direction: 'recv', method: 'Target.attachedToTarget', params: { sessionId: '', targetInfo: { attached: true, browserContextId: '', canAccessOpener: false, targetId: '', title: '/worker.js', type: 'worker', url: '/worker.js' }, waitingForDebugger: true }, sessionId: '' }, + { direction: 'send', method: 'Target.closeTarget', params: { targetId: '' }, sessionId: '' }, + { direction: 'recv', method: 'Target.targetInfoChanged', params: { targetInfo: { attached: false, browserContextId: '', canAccessOpener: false, targetId: '', title: '', type: 'page', url: '' } }, sessionId: '' }, + { direction: 'recv', method: 'Target.detachedFromTarget', params: { sessionId: '', targetId: '' }, sessionId: '' }, + { direction: 'recv', method: 'Target.targetDestroyed', params: { targetId: '' }, sessionId: '' }, + { direction: 'recv', method: 'Target.targetInfoChanged', params: { targetInfo: { attached: false, browserContextId: '', canAccessOpener: false, targetId: '', title: '/worker.js', type: 'worker', url: '/worker.js' } }, sessionId: '' }, + { direction: 'recv', method: 'Target.detachedFromTarget', params: { sessionId: '', targetId: '' }, sessionId: '' }, + { direction: 'resp', method: 'Target.closeTarget', result: { success: true } }, + ]; + assert.deepStrictEqual(actual, expected); + + // Tab should have been closed + assert.equal(window.browserTabs.length, 0); + }); +}); diff --git a/extensions/vscode-api-tests/testWorkspace/index.html b/extensions/vscode-api-tests/testWorkspace/index.html new file mode 100644 index 0000000000000..32d45d03b9ad6 --- /dev/null +++ b/extensions/vscode-api-tests/testWorkspace/index.html @@ -0,0 +1,15 @@ + + +Test Page + + + + diff --git a/extensions/vscode-api-tests/testWorkspace/worker.js b/extensions/vscode-api-tests/testWorkspace/worker.js new file mode 100644 index 0000000000000..85fd74bfe19db --- /dev/null +++ b/extensions/vscode-api-tests/testWorkspace/worker.js @@ -0,0 +1,2 @@ +// Worker script for browser tests – echoes messages back +self.onmessage = e => self.postMessage(e.data); diff --git a/package-lock.json b/package-lock.json index bf395838ca4ec..148fefa548611 100644 --- a/package-lock.json +++ b/package-lock.json @@ -95,8 +95,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260408", - "@vscode/component-explorer": "^0.2.1-8", - "@vscode/component-explorer-cli": "^0.2.1-7", + "@vscode/component-explorer": "^0.2.1-17", + "@vscode/component-explorer-cli": "^0.2.1-16", "@vscode/gulp-electron": "1.41.2", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", @@ -1286,9 +1286,9 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "dev": true, "license": "MIT", "engines": { @@ -1734,9 +1734,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1791,24 +1791,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -3407,26 +3389,26 @@ "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { - "version": "0.2.1-8", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-8.tgz", - "integrity": "sha512-yIBSrKe8rR/NU/vh7Lj3RYK38foI7BayALT/T2IL+R8EGAlnNZ4sKyAj16k7+ABiW9iDBTlqlUV9pmxK+e6XIA==", + "version": "0.2.1-17", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-17.tgz", + "integrity": "sha512-bDj9dKpgwjZwyoWF0hiP0Ee7YzzOVFBXj+/mk4dmNMAAKeAGaqgKFCFlDTYoDW8axmywq5dtA0enPdziTPKnPA==", "dev": true, "license": "MIT", "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": "^18.3.1", + "react-dom": "^18.3.1" } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.2.1-7", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-7.tgz", - "integrity": "sha512-onvVgbijBUlnmLIYRMyPKgypIPSAw6QoYTeDwxOOkDZPPeD6X3qM4Ss4QGcYVXTLF8J3yPo6vsUg3STujyA49A==", + "version": "0.2.1-16", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-16.tgz", + "integrity": "sha512-7qcXunaT0/1dGCLlgFvtJPkqlEb0GF0amnZIJp92hM42tscmKtLhiSVPmilgyZg/vAr1NRGf2yk/b1RBUM1YfQ==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.26.0", - "clipanion": "^4.0.0-rc.4", - "express": "^5.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "clipanion": "4.0.0-rc.4", + "express": "^5.2.1", "zod": "^4.3.6" }, "bin": { @@ -4630,6 +4612,48 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/amdefine": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", @@ -8562,9 +8586,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", - "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", "dev": true, "license": "MIT", "dependencies": { @@ -11653,9 +11677,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", "dev": true, "license": "MIT", "engines": { @@ -12644,9 +12668,9 @@ } }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "dev": true, "license": "MIT", "funding": { @@ -16046,9 +16070,9 @@ } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -16555,6 +16579,7 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -17250,14 +17275,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -20332,13 +20357,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "dev": true, "license": "ISC", "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } } } diff --git a/package.json b/package.json index 966cff92094a6..a8c63ee93f19f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.117.0", - "distro": "53515a4534020f9b4ffb3172649d2fdd0a5d9f63", + "distro": "cccc9754dda0a01ea6eaa276472ad5e169f80d31", "author": { "name": "Microsoft Corporation" }, @@ -79,6 +79,8 @@ "extensions-ci": "npm run gulp extensions-ci", "extensions-ci-pr": "npm run gulp extensions-ci-pr", "perf": "node scripts/code-perf.js", + "perf:chat": "node scripts/chat-simulation/test-chat-perf-regression.js", + "perf:chat-leak": "node scripts/chat-simulation/test-chat-mem-leaks.js", "copilot:setup": "npm --prefix extensions/copilot run setup", "copilot:get_token": "npm --prefix extensions/copilot run get_token", "update-build-ts-version": "npm install -D typescript@next && npm install -D @typescript/native-preview && (cd build && npm run typecheck)", @@ -172,8 +174,8 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.45.0", "@typescript/native-preview": "^7.0.0-dev.20260408", - "@vscode/component-explorer": "^0.2.1-8", - "@vscode/component-explorer-cli": "^0.2.1-7", + "@vscode/component-explorer": "^0.2.1-17", + "@vscode/component-explorer-cli": "^0.2.1-16", "@vscode/gulp-electron": "1.41.2", "@vscode/l10n-dev": "0.0.35", "@vscode/telemetry-extractor": "^1.20.2", diff --git a/product.json b/product.json index de24e13554674..5f9db5b15bb7f 100644 --- a/product.json +++ b/product.json @@ -193,50 +193,6 @@ "description": "Keyboard mapping from Notepad++" } ], - "onboardingExtensions": [ - { - "id": "ms-vscode-remote.remote-containers", - "name": "Dev Containers", - "publisher": "Microsoft", - "description": "Develop inside a container with a full-featured editor", - "icon": "remote-explorer" - }, - { - "id": "ms-azuretools.vscode-docker", - "name": "Docker", - "publisher": "Microsoft", - "description": "Build, manage, and deploy containerized applications", - "icon": "package" - }, - { - "id": "dbaeumer.vscode-eslint", - "name": "ESLint", - "publisher": "Microsoft", - "description": "Find and fix problems in your JavaScript code", - "icon": "lightbulb" - }, - { - "id": "GitHub.vscode-pull-request-github", - "name": "GitHub Pull Requests", - "publisher": "GitHub", - "description": "Review and manage GitHub pull requests and issues", - "icon": "git-pull-request" - }, - { - "id": "ms-python.python", - "name": "Python", - "publisher": "Microsoft", - "description": "Rich Python language support with IntelliSense and debugging", - "icon": "symbol-misc" - }, - { - "id": "ms-vscode-remote.remote-ssh", - "name": "Remote - SSH", - "publisher": "Microsoft", - "description": "Open folders and files on a remote machine via SSH", - "icon": "remote" - } - ], "onboardingThemes": [ { "id": "dark-2026", diff --git a/scripts/chat-simulation/common/mock-llm-server.js b/scripts/chat-simulation/common/mock-llm-server.js new file mode 100644 index 0000000000000..ff165dc7168e2 --- /dev/null +++ b/scripts/chat-simulation/common/mock-llm-server.js @@ -0,0 +1,1014 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +/** + * Local mock server that implements the OpenAI Chat Completions streaming API. + * Used by the chat perf benchmark to replace the real LLM backend with + * deterministic, zero-latency responses. + * + * Supports scenario-based responses: the `messages` array's last user message + * content is matched against scenario IDs. Unknown scenarios get a default + * text-only response. + */ + +const http = require('http'); +const path = require('path'); +const { EventEmitter } = require('events'); + +const ROOT = path.join(__dirname, '..', '..', '..'); + +// -- Scenario fixtures ------------------------------------------------------- + +/** + * @typedef {{ content: string, delayMs: number }} StreamChunk + */ + +/** + * A single turn in a multi-turn scenario. + * + * @typedef {{ + * kind: 'tool-calls', + * toolCalls: Array<{ toolNamePattern: RegExp, arguments: Record }>, + * } | { + * kind: 'content', + * chunks: StreamChunk[], + * } | { + * kind: 'thinking', + * thinkingChunks: StreamChunk[], + * chunks: StreamChunk[], + * } | { + * kind: 'user', + * message: string, + * }} ScenarioTurn + */ + +/** + * A scenario turn produced by the model. + * + * @typedef {{ + * kind: 'tool-calls', + * toolCalls: Array<{ toolNamePattern: RegExp, arguments: Record }>, + * } | { + * kind: 'content', + * chunks: StreamChunk[], + * } | { + * kind: 'thinking', + * thinkingChunks: StreamChunk[], + * chunks: StreamChunk[], + * }} ModelScenarioTurn + */ + +/** + * A model turn that emits content chunks. + * + * @typedef {{ + * kind: 'content', + * chunks: StreamChunk[], + * } | { + * kind: 'thinking', + * thinkingChunks: StreamChunk[], + * chunks: StreamChunk[], + * }} ContentScenarioTurn + */ + +/** + * A multi-turn scenario — an ordered sequence of turns. + * The mock server determines which model turn to serve based on the number + * of assistant→tool round-trips already present in the conversation. + * User turns are skipped by the server and instead injected by the test + * harness, which types them into the chat input and presses Enter. + * + * @typedef {{ + * type: 'multi-turn', + * turns: ScenarioTurn[], + * }} MultiTurnScenario + */ + +/** + * @param {any} scenario + * @returns {scenario is MultiTurnScenario} + */ +function isMultiTurnScenario(scenario) { + return scenario && typeof scenario === 'object' && scenario.type === 'multi-turn'; +} + +/** + * Helper for building scenario chunk sequences with timing control. + */ +class ScenarioBuilder { + constructor() { + /** @type {StreamChunk[]} */ + this.chunks = []; + } + + /** + * Emit a content chunk immediately (no delay before it). + * @param {string} content + * @returns {this} + */ + emit(content) { + this.chunks.push({ content, delayMs: 0 }); + return this; + } + + /** + * Wait, then emit a content chunk — simulates network/token generation latency. + * @param {number} ms - delay in milliseconds before this chunk + * @param {string} content + * @returns {this} + */ + wait(ms, content) { + this.chunks.push({ content, delayMs: ms }); + return this; + } + + /** + * Emit multiple chunks with uniform inter-chunk delay. + * @param {string[]} contents + * @param {number} [delayMs=15] - delay between each chunk (default ~1 frame) + * @returns {this} + */ + stream(contents, delayMs = 15) { + for (const content of contents) { + this.chunks.push({ content, delayMs }); + } + return this; + } + + /** + * Emit multiple chunks with no delay (burst). + * @param {string[]} contents + * @returns {this} + */ + burst(contents) { + return this.stream(contents, 0); + } + + /** @returns {StreamChunk[]} */ + build() { + return this.chunks; + } +} + +/** @type {Record} */ +const SCENARIOS = /** @type {Record} */ ({}); + +const DEFAULT_SCENARIO = 'text-only'; + +/** + * @returns {StreamChunk[]} + */ +function getDefaultScenarioChunks() { + const scenario = SCENARIOS[DEFAULT_SCENARIO]; + if (isMultiTurnScenario(scenario)) { + throw new Error(`Default scenario '${DEFAULT_SCENARIO}' must be content-only`); + } + return scenario; +} + +// -- SSE chunk builder ------------------------------------------------------- + +const MODEL = 'gpt-4o-2024-08-06'; + +/** + * @param {string} content + * @param {number} index + * @param {boolean} finish + */ +function makeChunk(content, index, finish) { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: finish ? {} : { content }, + finish_reason: finish ? 'stop' : null, + content_filter_results: {}, + }], + usage: null, + }; +} + +function makeInitialChunk() { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + content_filter_results: {}, + }], + usage: null, + }; +} + +/** + * Build a tool-call initial chunk (role only, no content). + */ +function makeToolCallInitialChunk() { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: { role: 'assistant', content: null }, + finish_reason: null, + content_filter_results: {}, + }], + usage: null, + }; +} + +/** + * Build a tool-call function-start chunk. + * @param {number} index - tool call index + * @param {string} callId - unique call ID + * @param {string} functionName - tool function name + */ +function makeToolCallStartChunk(index, callId, functionName) { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index, + id: callId, + type: 'function', + function: { name: functionName, arguments: '' }, + }], + }, + finish_reason: null, + content_filter_results: {}, + }], + usage: null, + }; +} + +/** + * Build a tool-call arguments chunk. + * @param {number} index - tool call index + * @param {string} argsFragment - partial JSON arguments + */ +function makeToolCallArgsChunk(index, argsFragment) { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index, + function: { arguments: argsFragment }, + }], + }, + finish_reason: null, + content_filter_results: {}, + }], + usage: null, + }; +} + +/** + * Build a tool-call finish chunk. + */ +function makeToolCallFinishChunk() { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: {}, + finish_reason: 'tool_calls', + content_filter_results: {}, + }], + usage: null, + }; +} + +/** + * Build a thinking (chain-of-thought summary) chunk. + * Uses the `cot_summary` field in the delta, matching the Copilot API wire format. + * @param {string} text - thinking text fragment + */ +function makeThinkingChunk(text) { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: { cot_summary: text }, + finish_reason: null, + content_filter_results: {}, + }], + usage: null, + }; +} + +/** + * Build a thinking ID chunk (sent after thinking text to close the block). + * @param {string} cotId - unique chain-of-thought ID + */ +function makeThinkingIdChunk(cotId) { + return { + id: 'chatcmpl-perf-benchmark', + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL, + choices: [{ + index: 0, + delta: { cot_id: cotId }, + finish_reason: null, + content_filter_results: {}, + }], + usage: null, + }; +} + +// -- Request handler --------------------------------------------------------- + +/** + * @param {http.IncomingMessage} req + * @param {http.ServerResponse} res + */ +function handleRequest(req, res) { + const contentLength = req.headers['content-length'] || '0'; + const ts = new Date().toISOString().slice(11, -1); // HH:MM:SS.mmm + console.log(`[mock-llm] ${ts} ${req.method} ${req.url} (${contentLength} bytes)`); + + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', '*'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const url = new URL(req.url || '/', `http://${req.headers.host}`); + const path = url.pathname; + const json = (/** @type {number} */ status, /** @type {any} */ data) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); + }; + const readBody = () => new Promise(resolve => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => resolve(body)); + }); + + // -- Health ------------------------------------------------------- + if (path === '/health') { res.writeHead(200); res.end('ok'); return; } + + // -- Token endpoints (DomainService.tokenURL / tokenNoAuthURL) ---- + // /copilot_internal/v2/token, /copilot_internal/v2/nltoken + if (path.startsWith('/copilot_internal/')) { + if (path.includes('/token') || path.includes('/nltoken')) { + json(200, { + token: 'perf-benchmark-fake-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_in: 1800, + sku: 'free_limited_copilot', + individual: true, + copilot_plan: 'free', + endpoints: { + api: `http://${req.headers.host}`, + proxy: `http://${req.headers.host}`, + }, + }); + } else { + // /copilot_internal/user, /copilot_internal/content_exclusion, etc. + json(200, {}); + } + return; + } + + // -- Telemetry (DomainService.telemetryURL) ---------------------- + if (path === '/telemetry') { json(200, {}); return; } + + // -- Model Router (DomainService.capiModelRouterURL = /models/session/intent) -- + // The automode service POSTs here to get the best model for a request. + if (path === '/models/session/intent' && req.method === 'POST') { + readBody().then(() => { + json(200, { model: MODEL }); + }); + return; + } + + // -- Auto Models / Model Session (DomainService.capiAutoModelURL = /models/session) -- + // Returns AutoModeAPIResponse: { available_models, session_token, expires_at } + if (path === '/models/session' && req.method === 'POST') { + readBody().then(() => { + json(200, { + available_models: [MODEL, 'gpt-4o-mini'], + session_token: 'perf-session-token-' + Date.now(), + expires_at: Math.floor(Date.now() / 1000) + 3600, + discounted_costs: {}, + }); + }); + return; + } + + // -- Models (DomainService.capiModelsURL = /models) -------------- + if (path === '/models' && req.method === 'GET') { + json(200, { + data: [ + { + id: MODEL, + name: 'GPT-4o (Mock)', + version: '2024-05-13', + vendor: 'copilot', + model_picker_enabled: true, + is_chat_default: true, + is_chat_fallback: true, + billing: { is_premium: false, multiplier: 0 }, + capabilities: { + type: 'chat', + family: 'gpt-4o', + tokenizer: 'o200k_base', + limits: { + max_prompt_tokens: 128000, + max_output_tokens: 131072, + max_context_window_tokens: 128000, + }, + supports: { + streaming: true, + tool_calls: true, + parallel_tool_calls: true, + vision: false, + }, + }, + supported_endpoints: ['/chat/completions'], + }, + { + id: 'gpt-4o-mini', + name: 'GPT-4o mini (Mock)', + version: '2024-07-18', + vendor: 'copilot', + model_picker_enabled: false, + is_chat_default: false, + is_chat_fallback: false, + billing: { is_premium: false, multiplier: 0 }, + capabilities: { + type: 'chat', + family: 'gpt-4o-mini', + tokenizer: 'o200k_base', + limits: { + max_prompt_tokens: 128000, + max_output_tokens: 131072, + max_context_window_tokens: 128000, + }, + supports: { + streaming: true, + tool_calls: true, + parallel_tool_calls: true, + vision: false, + }, + }, + supported_endpoints: ['/chat/completions'], + }, + ], + }); + return; + } + + // -- Model by ID (DomainService.capiModelsURL/{id}) -------------- + if (path.startsWith('/models/') && req.method === 'GET') { + const modelId = path.split('/models/')[1]?.split('/')[0]; + if (path.endsWith('/policy')) { + json(200, { state: 'accepted', terms: '' }); + return; + } + json(200, { + id: modelId || MODEL, + name: 'GPT-4o (Mock)', + version: '2024-05-13', + vendor: 'copilot', + model_picker_enabled: true, + is_chat_default: true, + is_chat_fallback: true, + capabilities: { + type: 'chat', + family: 'gpt-4o', + tokenizer: 'o200k_base', + limits: { max_prompt_tokens: 128000, max_output_tokens: 131072, max_context_window_tokens: 128000 }, + supports: { streaming: true, tool_calls: true, parallel_tool_calls: true, vision: false }, + }, + }); + return; + } + + // -- Agents (DomainService.remoteAgentsURL = /agents) ------------- + if (path.startsWith('/agents')) { + // /agents/sessions — CopilotSessions + if (path.includes('/sessions')) { + json(200, { sessions: [], total_count: 0, page_size: 20, page_number: 1 }); + } + // /agents/swe/models — CCAModelsList + else if (path.includes('/swe/models')) { + json(200, { + data: [{ + id: MODEL, name: 'GPT-4o (Mock)', vendor: 'copilot', + capabilities: { type: 'chat', family: 'gpt-4o', supports: { streaming: true } } + }] + }); + } + // /agents/swe/... — agent jobs, etc. + else if (path.includes('/swe/')) { + json(200, {}); + } + // /agents — list agents + else { + json(200, { agents: [] }); + } + return; + } + + // -- Chat Completions (DomainService.capiChatURL = /chat/completions) -- + if (path === '/chat/completions' && req.method === 'POST') { + readBody().then((/** @type {string} */ body) => handleChatCompletions(body, res)); + return; + } + + // -- Responses API (DomainService.capiResponsesURL = /responses) -- + if (path === '/responses' && req.method === 'POST') { + readBody().then((/** @type {string} */ body) => handleChatCompletions(body, res)); + return; + } + + // -- Messages API (DomainService.capiMessagesURL = /v1/messages) -- + if (path === '/v1/messages' && req.method === 'POST') { + readBody().then((/** @type {string} */ body) => handleChatCompletions(body, res)); + return; + } + + // -- Proxy completions (/v1/engines/*/completions) ---------------- + if (path.includes('/v1/engines/') && req.method === 'POST') { + readBody().then((/** @type {string} */ body) => handleChatCompletions(body, res)); + return; + } + + // -- Skills, Search, Embeddings ----------------------------------- + if (path === '/skills' || path.startsWith('/search/') || path.startsWith('/embeddings')) { + json(200, { data: [] }); + return; + } + + // -- Catch-all: any remaining POST with messages → chat completions + if (req.method === 'POST') { + readBody().then((/** @type {string} */ body) => { + try { + const parsed = JSON.parse(/** @type {string} */(body)); + if (parsed.messages && Array.isArray(parsed.messages)) { + handleChatCompletions(/** @type {string} */(body), res); + return; + } + } catch { } + json(200, {}); + }); + return; + } + + // -- Catch-all GET → empty success -------------------------------- + json(200, {}); +} + +// -- Server lifecycle -------------------------------------------------------- + +/** Emitted when a scenario chat completion is fully served. */ +const serverEvents = new EventEmitter(); + +/** @param {number} ms */ +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +/** + * Count the number of model turns already completed for the CURRENT scenario. + * Only counts assistant messages that appear after the last user message + * containing a [scenario:X] tag. This prevents assistant messages from + * previous scenarios (in the same chat session) from inflating the count. + * + * @param {any[]} messages + * @returns {number} + */ +function countCompletedModelTurns(messages) { + // Find the index of the last user message with a scenario tag + let scenarioMsgIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role !== 'user') { continue; } + const content = typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map((/** @type {any} */ c) => c.text || '').join('') + : ''; + if (/\[scenario:[^\]]+\]/.test(content)) { + scenarioMsgIdx = i; + break; + } + } + + // Count assistant messages after the scenario tag message + let turns = 0; + const startIdx = scenarioMsgIdx >= 0 ? scenarioMsgIdx + 1 : 0; + for (let i = startIdx; i < messages.length; i++) { + if (messages[i].role === 'assistant') { + turns++; + } + } + return turns; +} + +/** + * Compute the model-turn index for the current request given the scenario's + * turn list. User turns are skipped (they're handled by the test harness) + * and do not consume a model turn index. + * + * The algorithm counts completed assistant messages in the conversation + * history (each one = one served model turn), then maps that to the + * n-th model turn in the scenario (skipping user turns). + * + * @param {ScenarioTurn[]} turns + * @param {any[]} messages + * @returns {{ turn: ModelScenarioTurn, turnIndex: number }} + */ +function resolveCurrentTurn(turns, messages) { + const completedModelTurns = countCompletedModelTurns(messages); + // Build the model-only turn list (skip user turns) + const modelTurns = /** @type {ModelScenarioTurn[]} */ (turns.filter(t => t.kind !== 'user')); + const idx = Math.min(completedModelTurns, modelTurns.length - 1); + return { turn: modelTurns[idx], turnIndex: idx }; +} + +/** + * @param {string} body + * @param {http.ServerResponse} res + */ +async function handleChatCompletions(body, res) { + let scenarioId = DEFAULT_SCENARIO; + let isScenarioRequest = false; + /** @type {string[]} */ + let requestToolNames = []; + /** @type {any[]} */ + let messages = []; + try { + const parsed = JSON.parse(body); + messages = parsed.messages || []; + // Log user messages for debugging + const userMsgs = messages.filter((/** @type {any} */ m) => m.role === 'user'); + if (userMsgs.length > 0) { + const lastContent = typeof userMsgs[userMsgs.length - 1].content === 'string' + ? userMsgs[userMsgs.length - 1].content.substring(0, 100) + : '(structured)'; + const ts = new Date().toISOString().slice(11, -1); + console.log(`[mock-llm] ${ts} → ${messages.length} msgs, last user: "${lastContent}"`); + } + // Extract available tool names from the request's tools array + const tools = parsed.tools || []; + requestToolNames = tools.map((/** @type {any} */ t) => t.function?.name).filter(Boolean); + if (requestToolNames.length > 0) { + const ts = new Date().toISOString().slice(11, -1); + console.log(`[mock-llm] ${ts} → ${requestToolNames.length} tools available: ${requestToolNames.join(', ')}`); + } + + // Search user messages in reverse order (newest first) for the scenario + // tag. This ensures the most recent message's tag takes precedence when + // multiple messages with different tags exist in the same conversation + // (e.g. in the leak checker which sends many scenarios in one session). + // Follow-up user messages in multi-turn scenarios won't have a tag, so + // searching backwards still finds the correct tag from the initial message. + for (let mi = messages.length - 1; mi >= 0; mi--) { + const msg = messages[mi]; + if (msg.role !== 'user') { continue; } + const content = typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content.map((/** @type {any} */ c) => c.text || '').join('') + : ''; + const match = content.match(/\[scenario:([^\]]+)\]/); + if (match && SCENARIOS[match[1]]) { + scenarioId = match[1]; + isScenarioRequest = true; + break; + } + } + } catch { } + + const scenario = SCENARIOS[scenarioId] || SCENARIOS[DEFAULT_SCENARIO]; + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + 'X-Request-Id': 'perf-benchmark-' + Date.now(), + }); + + // Handle multi-turn scenarios — only when the request actually has tools. + // Ancillary requests (title generation, progress messages) also contain the + // [scenario:...] tag but don't send tools, so they fall through to content. + if (isMultiTurnScenario(scenario) && requestToolNames.length > 0) { + const { turn, turnIndex } = resolveCurrentTurn(scenario.turns, messages); + const modelTurnCount = scenario.turns.filter(t => t.kind !== 'user').length; + + const ts = new Date().toISOString().slice(11, -1); + console.log(`[mock-llm] ${ts} → multi-turn scenario ${scenarioId}, model turn ${turnIndex + 1}/${modelTurnCount} (${turn.kind}), ${countCompletedModelTurns(messages)} completed turns in history`); + + if (turn.kind === 'tool-calls') { + await streamToolCalls(res, turn.toolCalls, requestToolNames, scenarioId); + return; + } + + if (turn.kind === 'thinking') { + await streamThinkingThenContent(res, turn.thinkingChunks, turn.chunks, isScenarioRequest); + return; + } + + // kind === 'content' — stream the final text response + await streamContent(res, turn.chunks, isScenarioRequest); + return; + } + + // Standard content-only scenario (or multi-turn scenario falling back for + // ancillary requests like title generation that don't include tools) + const chunks = isMultiTurnScenario(scenario) + ? getFirstContentTurn(scenario) + : /** @type {StreamChunk[]} */ (scenario); + + await streamContent(res, chunks, isScenarioRequest); +} + +/** + * Get the chunks from the first content turn of a multi-turn scenario, + * used as fallback text for ancillary requests (title generation etc). + * @param {MultiTurnScenario} scenario + * @returns {StreamChunk[]} + */ +function getFirstContentTurn(scenario) { + /** @type {ContentScenarioTurn | undefined} */ + let contentTurn; + for (const turn of scenario.turns) { + if (turn.kind === 'content') { + contentTurn = turn; + break; + } + if (turn.kind === 'thinking') { + contentTurn = turn; + break; + } + } + return contentTurn?.chunks ?? getDefaultScenarioChunks(); +} + +/** + * Stream content chunks as a standard SSE response. + * @param {http.ServerResponse} res + * @param {StreamChunk[]} chunks + * @param {boolean} isScenarioRequest + */ +async function streamContent(res, chunks, isScenarioRequest) { + res.write(`data: ${JSON.stringify(makeInitialChunk())}\n\n`); + + for (const chunk of chunks) { + if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } + res.write(`data: ${JSON.stringify(makeChunk(chunk.content, 0, false))}\n\n`); + } + + res.write(`data: ${JSON.stringify(makeChunk('', 0, true))}\n\n`); + res.write('data: [DONE]\n\n'); + res.end(); + + if (isScenarioRequest) { + serverEvents.emit('scenarioCompletion'); + } +} + +/** + * Stream thinking chunks followed by content chunks as an SSE response. + * Thinking is emitted as `cot_summary` deltas, then a `cot_id` to close the + * thinking block, followed by standard content deltas. + * @param {http.ServerResponse} res + * @param {StreamChunk[]} thinkingChunks + * @param {StreamChunk[]} contentChunks + * @param {boolean} isScenarioRequest + */ +async function streamThinkingThenContent(res, thinkingChunks, contentChunks, isScenarioRequest) { + res.write(`data: ${JSON.stringify(makeInitialChunk())}\n\n`); + + // Stream thinking text + for (const chunk of thinkingChunks) { + if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } + res.write(`data: ${JSON.stringify(makeThinkingChunk(chunk.content))}\n\n`); + } + + // Close thinking block with ID + const cotId = `cot_perf_${Date.now()}`; + res.write(`data: ${JSON.stringify(makeThinkingIdChunk(cotId))}\n\n`); + await sleep(10); + + // Stream content + for (const chunk of contentChunks) { + if (chunk.delayMs > 0) { await sleep(chunk.delayMs); } + res.write(`data: ${JSON.stringify(makeChunk(chunk.content, 0, false))}\n\n`); + } + + res.write(`data: ${JSON.stringify(makeChunk('', 0, true))}\n\n`); + res.write('data: [DONE]\n\n'); + res.end(); + + if (isScenarioRequest) { + serverEvents.emit('scenarioCompletion'); + } +} + +/** + * Stream tool call chunks as an SSE response. + * @param {http.ServerResponse} res + * @param {Array<{ toolNamePattern: RegExp, arguments: Record }>} toolCalls + * @param {string[]} requestToolNames + * @param {string} scenarioId + */ +async function streamToolCalls(res, toolCalls, requestToolNames, scenarioId) { + res.write(`data: ${JSON.stringify(makeToolCallInitialChunk())}\n\n`); + + for (let i = 0; i < toolCalls.length; i++) { + const call = toolCalls[i]; + const callId = `call_perf_${scenarioId}_${i}_${Date.now()}`; + + // Find the matching tool name from the request's tools array + let toolName = requestToolNames.find(name => call.toolNamePattern.test(name)); + if (!toolName) { + toolName = call.toolNamePattern.source.replace(/[\\.|?*+^${}()\[\]]/g, ''); + console.warn(`[mock-llm] No matching tool for pattern ${call.toolNamePattern}, using fallback: ${toolName}`); + } + + // Stream tool call: start chunk, then arguments in fragments + res.write(`data: ${JSON.stringify(makeToolCallStartChunk(i, callId, toolName))}\n\n`); + await sleep(10); + + const argsJson = JSON.stringify(call.arguments); + const fragmentSize = Math.max(20, Math.ceil(argsJson.length / 4)); + for (let pos = 0; pos < argsJson.length; pos += fragmentSize) { + const fragment = argsJson.slice(pos, pos + fragmentSize); + res.write(`data: ${JSON.stringify(makeToolCallArgsChunk(i, fragment))}\n\n`); + await sleep(5); + } + } + + res.write(`data: ${JSON.stringify(makeToolCallFinishChunk())}\n\n`); + res.write('data: [DONE]\n\n'); + res.end(); +} + +/** + * Start the mock server and return a handle. + * @param {number} port + */ +function startServer(port = 0) { + return new Promise((resolve, reject) => { + let reqCount = 0; + let completions = 0; + /** @type {Array<() => boolean>} */ + let requestWaiters = []; + /** @type {Array<() => boolean>} */ + let completionWaiters = []; + + const onCompletion = () => { + completions++; + completionWaiters = completionWaiters.filter(fn => !fn()); + }; + serverEvents.on('scenarioCompletion', onCompletion); + + const server = http.createServer((req, res) => { + reqCount++; + requestWaiters = requestWaiters.filter(fn => !fn()); + handleRequest(req, res); + }); + server.listen(port, '127.0.0.1', () => { + const addr = server.address(); + const actualPort = typeof addr === 'object' && addr ? addr.port : port; + const url = `http://127.0.0.1:${actualPort}`; + resolve({ + port: actualPort, + url, + close: () => /** @type {Promise} */(new Promise((resolve, reject) => { + serverEvents.removeListener('scenarioCompletion', onCompletion); + server.close(err => err ? reject(err) : resolve(undefined)); + })), + /** Return total request count. */ + requestCount: () => reqCount, + /** + * Wait until at least `n` requests have been received. + * @param {number} n + * @param {number} timeoutMs + * @returns {Promise} + */ + waitForRequests: (n, timeoutMs) => new Promise((resolve, reject) => { + if (reqCount >= n) { resolve(); return; } + const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${n} requests (got ${reqCount})`)), timeoutMs); + requestWaiters.push(() => { + if (reqCount >= n) { clearTimeout(timer); resolve(); return true; } + return false; + }); + }), + /** Return total scenario-completion count. */ + completionCount: () => completions, + /** + * Wait until at least `n` scenario chat completions have been served. + * @param {number} n + * @param {number} timeoutMs + * @returns {Promise} + */ + waitForCompletion: (n, timeoutMs) => new Promise((resolve, reject) => { + if (completions >= n) { resolve(); return; } + const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${n} completions (got ${completions})`)), timeoutMs); + completionWaiters.push(() => { + if (completions >= n) { clearTimeout(timer); resolve(); return true; } + return false; + }); + }), + }); + }); + server.on('error', reject); + }); +} + +// Allow running standalone for testing: node scripts/mock-llm-server.js +if (require.main === module) { + const { registerPerfScenarios } = require('./perf-scenarios'); + registerPerfScenarios(); + const port = parseInt(process.argv[2] || '0', 10); + startServer(port).then((/** @type {any} */ handle) => { + console.log(`Mock LLM server listening at ${handle.url}`); + console.log('Scenarios:', Object.keys(SCENARIOS).join(', ')); + }); +} + +/** + * Get the user follow-up messages for a scenario, in order. + * Returns an array of { message, afterModelTurn } objects where afterModelTurn + * is the 0-based index of the model turn after which this user message should + * be injected. + * @param {string} scenarioId + * @returns {Array<{ message: string, afterModelTurn: number }>} + */ +function getUserTurns(scenarioId) { + const scenario = SCENARIOS[scenarioId]; + if (!isMultiTurnScenario(scenario)) { return []; } + const result = []; + let modelTurnsSeen = 0; + for (const turn of scenario.turns) { + if (turn.kind === 'user') { + result.push({ message: turn.message, afterModelTurn: modelTurnsSeen }); + } else { + modelTurnsSeen++; + } + } + return result; +} + +/** + * Get the total number of model turns (non-user turns) in a scenario. + * @param {string} scenarioId + * @returns {number} + */ +function getModelTurnCount(scenarioId) { + const scenario = SCENARIOS[scenarioId]; + if (!isMultiTurnScenario(scenario)) { return 1; } + return scenario.turns.filter(t => t.kind !== 'user').length; +} + +/** + * Register a scenario dynamically. Test files call this to add + * scenarios that are only relevant to them. + * @param {string} id - unique scenario identifier + * @param {StreamChunk[] | MultiTurnScenario} definition - scenario data + */ +function registerScenario(id, definition) { + SCENARIOS[id] = definition; +} + +/** + * Return the IDs of all currently registered scenarios. + * @returns {string[]} + */ +function getScenarioIds() { + return Object.keys(SCENARIOS); +} + +module.exports = { startServer, SCENARIOS, ScenarioBuilder, registerScenario, getScenarioIds, getUserTurns, getModelTurnCount }; diff --git a/scripts/chat-simulation/common/perf-scenarios.js b/scripts/chat-simulation/common/perf-scenarios.js new file mode 100644 index 0000000000000..8354101b04c8d --- /dev/null +++ b/scripts/chat-simulation/common/perf-scenarios.js @@ -0,0 +1,800 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +/** + * Built-in scenario definitions for chat performance benchmarks and leak checks. + * + * Each test file imports this module and calls `registerScenario()` for the + * scenarios it needs, keeping scenario ownership close to the test that uses it. + */ + +const path = require('path'); +const { ScenarioBuilder, registerScenario } = require('./mock-llm-server'); + +const FIXTURES_DIR = path.join(__dirname, '..', 'fixtures'); + +/** + * @typedef {{ + * description: string, + * chunks: import('./mock-llm-server').StreamChunk[], + * }} ContentScenarioDef + * + * @typedef {{ + * description: string, + * scenario: import('./mock-llm-server').MultiTurnScenario, + * }} MultiTurnScenarioDef + */ + +// -- Content-only scenarios --------------------------------------------------- + +/** @type {Record} */ +const CONTENT_SCENARIOS = { + 'text-only': { + description: 'Plain text, 4 paragraphs', + chunks: new ScenarioBuilder() + .stream([ + 'Here is an explanation of the code you selected:\n\n', + 'The function `processItems` iterates over the input array and applies a transformation to each element. ', + 'It uses a `Map` to track previously seen values, which allows it to deduplicate results efficiently in O(n) time.\n\n', + 'The algorithm works in a single pass: for every element, it computes the transformed value, ', + 'checks membership in the set, and conditionally appends to the output array. ', + 'This is a common pattern in data processing pipelines where uniqueness constraints must be maintained.\n\n', + 'Edge cases to consider include empty arrays, duplicate transformations that produce the same key, ', + 'and items where the transform function itself is expensive.\n\n', + 'The time complexity is **O(n)** and the space complexity is **O(n)** in the worst case when all items are unique.\n', + ], 20) + .build(), + }, + + 'large-codeblock': { + description: 'Single large TypeScript code block', + chunks: new ScenarioBuilder() + .stream([ + 'Here is the refactored implementation:\n\n', + '```typescript\n', + 'import { EventEmitter } from "events";\n\n', + 'interface CacheEntry {\n value: T;\n expiresAt: number;\n accessCount: number;\n}\n\n', + 'export class LRUCache {\n', + ' private readonly _map = new Map>();\n', + ' private readonly _emitter = new EventEmitter();\n\n', + ' constructor(\n private readonly _maxSize: number,\n private readonly _ttlMs: number = 60_000,\n ) {}\n\n', + ' get(key: K): V | undefined {\n const entry = this._map.get(key);\n if (!entry) { return undefined; }\n', + ' if (Date.now() > entry.expiresAt) {\n this._map.delete(key);\n this._emitter.emit("evict", key);\n return undefined;\n }\n', + ' entry.accessCount++;\n this._map.delete(key);\n this._map.set(key, entry);\n return entry.value;\n }\n\n', + ' set(key: K, value: V): void {\n if (this._map.size >= this._maxSize) {\n', + ' const oldest = this._map.keys().next().value;\n if (oldest !== undefined) {\n this._map.delete(oldest);\n this._emitter.emit("evict", oldest);\n }\n }\n', + ' this._map.set(key, { value, expiresAt: Date.now() + this._ttlMs, accessCount: 0 });\n }\n\n', + ' clear(): void { this._map.clear(); this._emitter.emit("clear"); }\n', + ' get size(): number { return this._map.size; }\n', + ' onEvict(listener: (key: K) => void): void { this._emitter.on("evict", listener); }\n}\n', + '```\n\n', + 'The key changes:\n- Added TTL-based expiry with configurable timeout\n- LRU eviction uses Map insertion order\n- EventEmitter notifies on evictions for cache observability\n', + ], 20) + .build(), + }, + + 'many-small-chunks': { + description: '200 word-level chunks at 5ms', + chunks: (() => { + const words = ['Generating detailed analysis:\n\n']; + for (let i = 0; i < 200; i++) { words.push(`Word${i} `); } + words.push('\n\nAnalysis complete.\n'); + const b = new ScenarioBuilder(); + b.stream(words, 5); + return b.build(); + })(), + }, + + 'mixed-content': { + description: 'Markdown + code block + fix suggestion', + chunks: new ScenarioBuilder() + .stream([ + '## Issue Found\n\n', + 'The `DisposableStore` is not being disposed in the `deactivate` path, ', + 'which can lead to memory leaks.\n\n', + '### Current Code\n\n', + '```typescript\nclass MyService {\n private store = new DisposableStore();\n // missing dispose!\n}\n```\n\n', + '### Suggested Fix\n\n', + '```typescript\nclass MyService extends Disposable {\n', + ' private readonly store = this._register(new DisposableStore());\n\n', + ' override dispose(): void {\n this.store.dispose();\n super.dispose();\n }\n}\n```\n\n', + 'This ensures the store is cleaned up when the service is disposed via the workbench lifecycle.\n', + ], 20) + .build(), + }, + + // -- Stress-test scenarios -------------------------------------------- + + 'many-codeblocks': { + description: '10 code blocks, 60 lines each', + chunks: (() => { + const b = new ScenarioBuilder(); + b.emit('Here are the implementations for each module:\n\n'); + for (let i = 0; i < 10; i++) { + b.wait(10, `### Module ${i + 1}: \`handler${i}.ts\`\n\n`); + b.emit('```typescript\n'); + const lines = []; + for (let j = 0; j < 15; j++) { + lines.push(`export function handle${i}_${j}(input: string): string {\n`); + lines.push(` const result = input.trim().split('').reverse().join('');\n`); + lines.push(` return \`[\${result}] processed by handler ${i}_${j}\`;\n`); + lines.push('}\n\n'); + } + b.stream(lines, 5); + b.emit('```\n\n'); + } + b.emit('All modules implement the same pattern with unique handler IDs.\n'); + return b.build(); + })(), + }, + + 'long-prose': { + description: '15 sections, ~3000 words of prose', + chunks: (() => { + const sentences = [ + 'The architecture follows a layered dependency injection pattern where each service declares its dependencies through constructor parameters. ', + 'This approach ensures that circular dependencies are detected at compile time rather than at runtime, which significantly reduces debugging overhead. ', + 'When a service is instantiated, the instantiation service resolves all of its dependencies recursively, creating a directed acyclic graph of service instances. ', + 'Each service is a singleton within its scope, meaning that multiple consumers of the same service interface receive the same instance. ', + 'The workbench lifecycle manages the creation and disposal of these services through well-defined phases: creation, restoration, and eventual shutdown. ', + 'During the restoration phase, services that persist state across sessions reload their data from storage, which may involve asynchronous operations. ', + 'Contributors register their functionality through extension points, which are processed during the appropriate lifecycle phase. ', + 'This contribution model allows features to be added without modifying the core workbench code, maintaining a clean separation of concerns. ', + ]; + const b = new ScenarioBuilder(); + b.emit('# Detailed Architecture Analysis\n\n'); + for (let para = 0; para < 15; para++) { + b.wait(15, `## Section ${para + 1}: ${['Overview', 'Design Patterns', 'Service Layer', 'Event System', 'State Management', 'Error Handling', 'Performance', 'Testing', 'Deployment', 'Monitoring', 'Security', 'Extensibility', 'Compatibility', 'Migration', 'Future Work'][para]}\n\n`); + const paraSentences = []; + for (let s = 0; s < 25; s++) { paraSentences.push(sentences[s % sentences.length]); } + b.stream(paraSentences, 8); + b.emit('\n\n'); + } + return b.build(); + })(), + }, + + 'rich-markdown': { + description: '6 sections × 5 items, bold/links/code spans', + chunks: (() => { + const b = new ScenarioBuilder(); + b.emit('# Comprehensive Code Review Report\n\n'); + b.wait(15, '> **Summary**: Found 12 issues across 4 severity levels.\n\n'); + for (let section = 0; section < 6; section++) { + b.wait(10, `## ${section + 1}. ${['Critical Issues', 'Performance Concerns', 'Code Style', 'Documentation Gaps', 'Test Coverage', 'Security Review'][section]}\n\n`); + for (let item = 0; item < 5; item++) { + b.stream([ + `${item + 1}. **Issue ${section * 5 + item + 1}**: \`${['useState', 'useEffect', 'useMemo', 'useCallback', 'useRef'][item]}\` in \`src/components/Widget${item}.tsx\`\n`, + ` - Severity: ${['[Critical]', '[Warning]', '[Info]', '[Suggestion]', '[Note]'][item]}\n`, + ` - The current implementation uses *unnecessary re-renders* due to missing dependency arrays.\n`, + ` - See [React docs](https://react.dev/reference) and the [\`useMemo\` guide](https://react.dev/reference/react/useMemo).\n`, + ` - Fix: wrap in \`useCallback\` or extract to a ***separate memoized component***.\n\n`, + ], 10); + } + b.emit('---\n\n'); + } + b.emit('> *Report generated automatically. Please review all suggestions before applying.*\n'); + return b.build(); + })(), + }, + + 'giant-codeblock': { + description: '40 classes in one fenced code block', + chunks: (() => { + const b = new ScenarioBuilder(); + b.emit('Here is the complete implementation:\n\n```typescript\n'); + b.stream([ + 'import { Disposable, DisposableStore } from "vs/base/common/lifecycle";\n', + 'import { Emitter, Event } from "vs/base/common/event";\n', + 'import { URI } from "vs/base/common/uri";\n\n', + ], 10); + for (let i = 0; i < 40; i++) { + b.stream([ + `export class Service${i} extends Disposable {\n`, + ` private readonly _onDidChange = this._register(new Emitter());\n`, + ` readonly onDidChange: Event = this._onDidChange.event;\n\n`, + ` private _value: string = '';\n`, + ` get value(): string { return this._value; }\n\n`, + ` async update(uri: URI): Promise {\n`, + ` this._value = uri.toString();\n`, + ` this._onDidChange.fire();\n`, + ` }\n`, + '}\n\n', + ], 5); + } + b.emit('```\n\nThis defines 40 service classes following the standard VS Code pattern.\n'); + return b.build(); + })(), + }, + + 'rapid-stream': { + description: '1000 tokens at 2ms (streaming stress test)', + chunks: (() => { + const b = new ScenarioBuilder(); + const words = []; + for (let i = 0; i < 1000; i++) { words.push(`w${i} `); } + // Very fast inter-chunk delay to stress the streaming pipeline + b.stream(words, 2); + return b.build(); + })(), + }, + + 'file-links': { + description: '32 file references with line links', + chunks: (() => { + const files = [ + 'src/vs/workbench/contrib/chat/browser/chatListRenderer.ts', + 'src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts', + 'src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts', + 'src/vs/workbench/contrib/chat/common/chatPerf.ts', + 'src/vs/base/common/lifecycle.ts', + 'src/vs/base/common/event.ts', + 'src/vs/platform/instantiation/common/instantiation.ts', + 'src/vs/workbench/services/extensions/common/abstractExtensionService.ts', + 'src/vs/workbench/api/common/extHostLanguageModels.ts', + 'src/vs/workbench/contrib/chat/common/languageModels.ts', + 'src/vs/editor/browser/widget/codeEditor/editor.ts', + 'src/vs/workbench/browser/parts/editor/editorGroupView.ts', + ]; + const b = new ScenarioBuilder(); + b.emit('I found references to the disposable pattern across the following files:\n\n'); + for (let i = 0; i < files.length; i++) { + const line = Math.floor(Math.random() * 500) + 1; + b.stream([ + `${i + 1}. [${files[i]}](${files[i]}#L${line}) -- `, + `Line ${line}: uses \`DisposableStore\` with ${Math.floor(Math.random() * 10) + 1} registrations\n`, + ], 15); + } + b.wait(10, '\nAdditionally, the following files import from `vs/base/common/lifecycle`:\n\n'); + for (let i = 0; i < 20; i++) { + const depth = ['base', 'platform', 'editor', 'workbench'][i % 4]; + const area = ['common', 'browser', 'node', 'electron-browser'][i % 4]; + const name = ['service', 'provider', 'contribution', 'handler', 'manager'][i % 5]; + const file = `src/vs/${depth}/${area}/${name}${i}.ts`; + b.stream([ + `- [${file}](${file}#L${i * 10 + 5})`, + ` -- imports \`Disposable\`, \`DisposableStore\`\n`, + ], 12); + } + b.emit('\nTotal: 32 files reference the disposable pattern.\n'); + return b.build(); + })(), + }, +}; + +// -- Tool call scenarios ------------------------------------------------------ + +/** @type {Record} */ +const TOOL_CALL_SCENARIOS = { + 'tool-read-file': { + description: 'Read 8 files across 2 tool-call rounds', + scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ((() => { + const filesToRead = [ + '_chatperf_lifecycle.ts', + '_chatperf_event.ts', + '_chatperf_uri.ts', + '_chatperf_errors.ts', + '_chatperf_async.ts', + '_chatperf_strings.ts', + '_chatperf_arrays.ts', + '_chatperf_types.ts', + ]; + // Round 1: parallel read of first 4 files + // Round 2: parallel read of next 4 files + // Round 3: final content response + return { + type: 'multi-turn', + turns: [ + { + kind: 'tool-calls', + toolCalls: filesToRead.slice(0, 4).map(f => ({ + toolNamePattern: /read.?file/i, + arguments: { filePath: path.join(FIXTURES_DIR, f), startLine: 1, endLine: 50 }, + })), + }, + { + kind: 'tool-calls', + toolCalls: filesToRead.slice(4).map(f => ({ + toolNamePattern: /read.?file/i, + arguments: { filePath: path.join(FIXTURES_DIR, f), startLine: 1, endLine: 50 }, + })), + }, + { + kind: 'content', + chunks: new ScenarioBuilder() + .wait(20, '## Analysis of VS Code Base Utilities\n\n') + .stream([ + 'I read 8 core utility files from `src/vs/base/common/`. Here is a summary:\n\n', + '### lifecycle.ts\n', + 'The `Disposable` base class provides the standard lifecycle pattern. Components register cleanup ', + 'handlers via `this._register()` which are automatically disposed when the parent is disposed.\n\n', + '### event.ts\n', + 'The `Emitter` class implements the observer pattern. `Event.once()`, `Event.map()`, and `Event.filter()` ', + 'provide functional combinators for composing event streams.\n\n', + '### uri.ts\n', + '`URI` is an immutable representation of a resource identifier with scheme, authority, path, query, and fragment.\n\n', + '### errors.ts\n', + 'Central error handling with `onUnexpectedError()` and `isCancellationError()` for distinguishing user cancellation.\n\n', + '### async.ts\n', + '`Throttler`, `Delayer`, `RunOnceScheduler`, and `Queue` manage async operation scheduling and deduplication.\n\n', + '### strings.ts\n', + 'String utilities including `format()`, `escape()`, `startsWith()`, and `endsWith()` for common string operations.\n\n', + '### arrays.ts\n', + 'Array helpers like `coalesce()`, `groupBy()`, `distinct()`, and binary search implementations.\n\n', + '### types.ts\n', + 'Type guards and assertion helpers: `isString()`, `isNumber()`, `assertType()`, `assertIsDefined()`.\n', + ], 15) + .build(), + }, + ], + }; + })()), + }, + + 'tool-edit-file': { + description: 'Read 3 files, edit 2 (read + write rounds)', + scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ((() => { + const readFiles = [ + '_chatperf_lifecycle.ts', + '_chatperf_event.ts', + '_chatperf_errors.ts', + ]; + return { + type: 'multi-turn', + turns: [ + // Round 1: read all 3 files in parallel + { + kind: 'tool-calls', + toolCalls: readFiles.map(f => ({ + toolNamePattern: /read.?file/i, + arguments: { filePath: path.join(FIXTURES_DIR, f), startLine: 1, endLine: 40 }, + })), + }, + // Round 2: edit 2 files in parallel + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /insert.?edit|replace.?string|apply.?patch/i, + arguments: { + filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'), + explanation: 'Update the benchmark marker comment in lifecycle.ts', + code: '// perf-benchmark-marker (updated)', + }, + }, + { + toolNamePattern: /insert.?edit|replace.?string|apply.?patch/i, + arguments: { + filePath: path.join(FIXTURES_DIR, '_chatperf_event.ts'), + explanation: 'Update the benchmark marker comment in event.ts', + code: '// perf-benchmark-marker (updated)', + }, + }, + ], + }, + // Round 3: final content + { + kind: 'content', + chunks: new ScenarioBuilder() + .wait(20, '## Edits Applied\n\n') + .stream([ + 'I read 3 files and applied edits to 2 of them:\n\n', + '### Files read:\n', + '1. `src/vs/base/common/lifecycle.ts` — Disposable pattern and lifecycle management\n', + '2. `src/vs/base/common/event.ts` — Event emitter and observer pattern\n', + '3. `src/vs/base/common/errors.ts` — Error handling utilities\n\n', + '### Edits applied:\n', + '1. **lifecycle.ts** — Updated the benchmark marker comment\n', + '2. **event.ts** — Updated the benchmark marker comment\n\n', + 'Both files follow the standard VS Code pattern of using `Disposable` as a base class ', + 'with `_register()` for lifecycle management. The edits were minimal and localized.\n', + ], 20) + .build(), + }, + ], + }; + })()), + }, + + 'tool-terminal': { + description: 'Run commands, read output, fix + rerun', + scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ({ + type: 'multi-turn', + turns: [ + // Round 1: run initial commands (install + build) + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /run.?in.?terminal|execute.?command/i, + arguments: { + command: 'echo "Installing dependencies..." && echo "added 1631 packages in 6m"', + explanation: 'Install project dependencies', + goal: 'Install dependencies', + mode: 'sync', + timeout: 30000, + }, + }, + ], + }, + // Round 2: run test command + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /run.?in.?terminal|execute.?command/i, + arguments: { + command: 'echo "Running unit tests..." && echo " 42 passing (3s)" && echo " 2 failing" && echo "" && echo " 1) ChatService should dispose listeners" && echo " AssertionError: expected 0 to equal 1" && echo " 2) ChatModel should clear on new session" && echo " TypeError: Cannot read property dispose of undefined"', + explanation: 'Run the unit test suite to check for failures', + goal: 'Run tests', + mode: 'sync', + timeout: 60000, + }, + }, + ], + }, + // Round 3: read the failing test file for context + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /read.?file/i, + arguments: { filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'), startLine: 1, endLine: 50 }, + }, + ], + }, + // Round 4: fix the issue with an edit + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /insert.?edit|replace.?string|apply.?patch/i, + arguments: { + filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'), + explanation: 'Fix the dispose call in the test', + code: '// perf-benchmark-marker (fixed)', + }, + }, + ], + }, + // Round 5: re-run tests to confirm + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /run.?in.?terminal|execute.?command/i, + arguments: { + command: 'echo "Running unit tests..." && echo " 44 passing (3s)" && echo " 0 failing"', + explanation: 'Re-run tests to verify the fix', + goal: 'Verify fix', + mode: 'sync', + timeout: 60000, + }, + }, + ], + }, + // Round 6: final summary + { + kind: 'content', + chunks: new ScenarioBuilder() + .wait(20, '## Test Failures Fixed\n\n') + .stream([ + 'I found and fixed 2 test failures:\n\n', + '### Root Cause\n', + 'The `ChatService` was not properly disposing event listeners when a session was cleared. ', + 'The `dispose()` method was missing a call to `this._store.dispose()`.\n\n', + '### Changes Made\n', + 'Updated `lifecycle.ts` to properly chain disposal:\n\n', + '```typescript\n', + 'override dispose(): void {\n', + ' this._store.dispose();\n', + ' super.dispose();\n', + '}\n', + '```\n\n', + '### Test Results\n', + '- **Before**: 42 passing, 2 failing\n', + '- **After**: 44 passing, 0 failing\n\n', + 'All tests pass now. The fix ensures listeners are cleaned up during session transitions.\n', + ], 15) + .build(), + }, + ], + }), + }, +}; + +// -- Multi-turn user conversation scenarios ----------------------------------- + +/** @type {Record} */ +const MULTI_TURN_SCENARIOS = { + 'thinking-response': { + description: 'Thinking block before content response', + scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ({ + type: 'multi-turn', + turns: [ + { + kind: 'thinking', + thinkingChunks: new ScenarioBuilder() + .stream([ + 'Let me analyze this code carefully. ', + 'The user is asking about the lifecycle pattern in VS Code. ', + 'I should look at the Disposable base class and how it manages cleanup. ', + 'The key methods are _register(), dispose(), and the DisposableStore pattern. ', + 'I need to read the file first to give an accurate explanation.', + ], 15) + .build(), + chunks: new ScenarioBuilder() + .wait(20, 'I\'ll start by reading the file to understand its structure.\n\n') + .stream([ + 'The `Disposable` base class in `lifecycle.ts` provides a standard pattern ', + 'for managing resources. It uses a `DisposableStore` internally to track ', + 'all registered disposables and clean them up on `dispose()`.\n', + ], 20) + .build(), + }, + ], + }), + }, + + 'multi-turn-user': { + description: '2 user follow-ups with thinking + code', + scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ({ + type: 'multi-turn', + turns: [ + // Turn 1: Model reads a file + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /read.?file/i, + arguments: { + filePath: path.join(FIXTURES_DIR, '_chatperf_lifecycle.ts'), + offset: 1, + limit: 50, + }, + }, + ], + }, + // Turn 2: Model responds with analysis + { + kind: 'content', + chunks: new ScenarioBuilder() + .wait(20, 'I\'ve read the file. Here\'s what I found:\n\n') + .stream([ + 'The `Disposable` class is the base for lifecycle management. ', + 'It internally holds a `DisposableStore` via `this._store`. ', + 'Subclasses call `this._register()` to track their own disposables.\n\n', + 'Would you like me to explain any specific part in more detail?\n', + ], 20) + .build(), + }, + // Turn 3: User follow-up (injected by test harness, not served by mock) + { + kind: 'user', + message: 'Yes, explain the MutableDisposable pattern', + }, + // Turn 4: Model responds with thinking, then content + { + kind: 'thinking', + thinkingChunks: new ScenarioBuilder() + .stream([ + 'The user wants to understand MutableDisposable specifically. ', + 'Let me recall the key aspects: it holds a single disposable that can be swapped. ', + 'When a new value is set, the old one is automatically disposed. ', + 'This is useful for things like event listener subscriptions that need to be replaced.', + ], 10) + .build(), + chunks: new ScenarioBuilder() + .wait(15, '## MutableDisposable\n\n') + .stream([ + '`MutableDisposable` holds a **single disposable** that can be swapped at any time. ', + 'When you set a new value via `.value = newDisposable`, the previous value is automatically disposed.\n\n', + 'This is perfect for:\n', + '- **Event listeners** that need to be re-subscribed when configuration changes\n', + '- **Editor decorations** that are replaced when content updates\n', + '- **Watchers** that switch targets dynamically\n\n', + '```typescript\n', + 'class MyService extends Disposable {\n', + ' private readonly _listener = this._register(new MutableDisposable());\n\n', + ' updateTarget(editor: ICodeEditor): void {\n', + ' // Old listener is automatically disposed\n', + ' this._listener.value = editor.onDidChangeModel(() => {\n', + ' this._handleModelChange();\n', + ' });\n', + ' }\n', + '}\n', + '```\n\n', + 'The key benefit is that you never forget to dispose the old subscription.\n', + ], 15) + .build(), + }, + // Turn 5: Second user follow-up + { + kind: 'user', + message: 'Can you also show me DisposableMap?', + }, + // Turn 6: Final response + { + kind: 'content', + chunks: new ScenarioBuilder() + .wait(20, '## DisposableMap\n\n') + .stream([ + '`DisposableMap` extends `Map` with automatic disposal semantics:\n\n', + '- When a key is **overwritten**, the old value is disposed\n', + '- When a key is **deleted**, the value is disposed\n', + '- When the map itself is **disposed**, all values are disposed\n\n', + '```typescript\n', + 'class ToolManager extends Disposable {\n', + ' private readonly _tools = this._register(new DisposableMap());\n\n', + ' registerTool(id: string, tool: IDisposable): void {\n', + ' this._tools.set(id, tool); // auto-disposes previous tool with same id\n', + ' }\n', + '}\n', + '```\n\n', + 'This is commonly used for managing collections of disposable resources keyed by ID.\n', + ], 15) + .build(), + }, + ], + }), + }, + 'long-conversation': { + description: '10 user turns, mixed content types', + scenario: /** @type {import('./mock-llm-server').MultiTurnScenario} */ ((() => { + const topics = [ + { question: 'How does the Disposable pattern work?', heading: 'Disposable Pattern', content: 'The `Disposable` base class provides lifecycle management. Subclasses call `this._register()` to track child disposables that are cleaned up automatically when `dispose()` is called.' }, + { question: 'What about DisposableStore?', heading: 'DisposableStore', content: '`DisposableStore` aggregates multiple `IDisposable` instances and disposes them all at once. It tracks whether it has already been disposed and throws if you try to add after disposal.' }, + { question: 'How does the Event system work?', heading: 'Event System', content: 'The `Emitter` class implements the observer pattern. `Event.once()`, `Event.map()`, `Event.filter()`, and `Event.debounce()` provide functional combinators for composing event streams.' }, + { question: 'Explain dependency injection', heading: 'Dependency Injection', content: 'Services are injected through constructor parameters decorated with service identifiers. The `IInstantiationService` resolves dependencies recursively, creating singletons within each scope.' }, + { question: 'What is the contribution model?', heading: 'Contribution Model', content: 'Features register functionality through extension points like `Registry.as()`. Contributions are instantiated during specific lifecycle phases.' }, + { question: 'How does the editor handle text models?', heading: 'Text Models', content: 'The `TextModel` class manages document content with line-based storage. It supports undo/redo stacks, bracket matching, tokenization, and change tracking via edit operations.' }, + { question: 'Explain the extension host architecture', heading: 'Extension Host', content: 'Extensions run in a separate process (or worker) called the extension host. Communication happens via an RPC protocol over `IPC`. The main process proxies API calls back to the workbench.' }, + { question: 'How does file watching work?', heading: 'File Watching', content: 'The `IFileService` supports correlated and shared file watchers. Correlated watchers are preferred as they track specific resources. The underlying implementation uses `chokidar` or `parcel/watcher`.' }, + { question: 'What about the tree widget?', heading: 'Tree Widget', content: 'The `AsyncDataTree` and `ObjectTree` provide virtualized tree rendering. They support filtering, sorting, keyboard navigation, and accessibility. The `ITreeRenderer` interface handles element rendering.' }, + { question: 'How does the settings editor work?', heading: 'Settings Editor', content: 'Settings are declared in `package.json` contribution points. The settings editor reads the configuration registry, groups settings by category, and renders appropriate input controls for each type.' }, + ]; + + /** @type {import('./mock-llm-server').ScenarioTurn[]} */ + const turns = []; + + // Turn 1: Initial model response (no user turn needed before the first) + const firstTopic = topics[0]; + turns.push({ + kind: 'content', + chunks: new ScenarioBuilder() + .wait(20, `## ${firstTopic.heading}\n\n`) + .stream([ + `${firstTopic.content}\n\n`, + 'Here is a typical example:\n\n', + '```typescript\n', + 'class MyService extends Disposable {\n', + ' private readonly _onDidChange = this._register(new Emitter());\n', + ' readonly onDidChange: Event = this._onDidChange.event;\n\n', + ' constructor(@IFileService private readonly fileService: IFileService) {\n', + ' super();\n', + ' this._register(fileService.onDidFilesChange(e => this._handleChange(e)));\n', + ' }\n', + '}\n', + '```\n\n', + 'Would you like to know more about any specific aspect?\n', + ], 15) + .build(), + }); + + // Turns 2..N: alternating user follow-up + model response + for (let i = 1; i < topics.length; i++) { + const topic = topics[i]; + + // User follow-up + turns.push({ kind: 'user', message: topic.question }); + + // Model response — vary content type to stress different renderers + const b = new ScenarioBuilder(); + b.wait(20, `## ${topic.heading}\n\n`); + + // Main explanation + const sentences = topic.content.split('. '); + b.stream(sentences.map(s => s.endsWith('.') ? s + ' ' : s + '. '), 12); + b.emit('\n\n'); + + if (i % 3 === 0) { + // Every 3rd response: large code block + b.emit('```typescript\n'); + for (let j = 0; j < 8; j++) { + b.stream([ + `export class ${topic.heading.replace(/\s/g, '')}Part${j} extends Disposable {\n`, + ` private readonly _state = new Map();\n\n`, + ` process(input: string): string {\n`, + ` const cached = this._state.get(input);\n`, + ` if (cached) { return String(cached); }\n`, + ` const result = input.split('').reverse().join('');\n`, + ` this._state.set(input, result);\n`, + ` return result;\n`, + ` }\n`, + '}\n\n', + ], 5); + } + b.emit('```\n\n'); + } else if (i % 3 === 1) { + // Every 3rd+1 response: bullet list with bold + inline code + b.emit('Key points to remember:\n\n'); + for (let j = 0; j < 6; j++) { + b.stream([ + `${j + 1}. **Point ${j + 1}**: The \`${topic.heading.replace(/\s/g, '')}${j}\` `, + `component uses the standard pattern with \`_register()\` for lifecycle. `, + `It handles edge cases like ${['empty input', 'null references', 'concurrent access', 'circular deps', 'timeout expiry', 'disposal races'][j]}.\n`, + ], 10); + } + b.emit('\n'); + } else { + // Every 3rd+2 response: mixed prose + small code snippet + b.stream([ + 'This pattern is used extensively throughout the codebase. ', + 'The key insight is that resources are always tracked from creation, ', + 'ensuring no leaks even in error paths. ', + 'The ownership chain is explicit and follows the component hierarchy.\n\n', + ], 12); + b.emit('Quick example:\n\n```typescript\n'); + b.stream([ + `const store = new DisposableStore();\n`, + `store.add(event.on(() => { /* handler */ }));\n`, + `store.add(watcher.watch(uri));\n`, + `// Later: store.dispose(); // cleans up everything\n`, + ], 8); + b.emit('```\n\n'); + } + + b.stream([ + `That covers the essentials of **${topic.heading}**. `, + 'Let me know if you want to dive deeper into any of these concepts.\n', + ], 15); + + turns.push({ + kind: 'content', + chunks: b.build(), + }); + } + + return { type: 'multi-turn', turns }; + })()), + }, +}; + +// -- Registration helper ------------------------------------------------------ + +/** + * Get a brief description of a scenario by ID. + * @param {string} id + * @returns {string} + */ +function getScenarioDescription(id) { + const content = CONTENT_SCENARIOS[id]; + if (content) { return content.description; } + const tool = TOOL_CALL_SCENARIOS[id]; + if (tool) { return tool.description; } + const multi = MULTI_TURN_SCENARIOS[id]; + if (multi) { return multi.description; } + return ''; +} + +/** + * Register all built-in perf scenarios into the mock LLM server. + * Call this from your test file before starting the server. + */ +function registerPerfScenarios() { + for (const [id, def] of Object.entries(CONTENT_SCENARIOS)) { + registerScenario(id, def.chunks); + } + for (const [id, def] of Object.entries(TOOL_CALL_SCENARIOS)) { + registerScenario(id, def.scenario); + } + for (const [id, def] of Object.entries(MULTI_TURN_SCENARIOS)) { + registerScenario(id, def.scenario); + } +} + +module.exports = { registerPerfScenarios, getScenarioDescription, CONTENT_SCENARIOS, TOOL_CALL_SCENARIOS, MULTI_TURN_SCENARIOS }; diff --git a/scripts/chat-simulation/common/utils.js b/scripts/chat-simulation/common/utils.js new file mode 100644 index 0000000000000..6d8adfd76e128 --- /dev/null +++ b/scripts/chat-simulation/common/utils.js @@ -0,0 +1,835 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +/** + * Shared utilities for chat performance benchmarks and leak checks. + * + * Platform: macOS and Linux only. Windows is not supported — several + * utilities (`sqlite3`, `sleep`, `pkill`) are Unix-specific. + * CI runs on ubuntu-latest. + */ + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const http = require('http'); +const { execSync, execFileSync, spawn } = require('child_process'); + +const ROOT = path.join(__dirname, '..', '..', '..'); +const DATA_DIR = path.join(ROOT, '.chat-simulation-data'); + +// -- Config loading ---------------------------------------------------------- + +/** @param {string} text */ +function stripJsoncComments(text) { return text.replace(/\/\/.*/g, '').replace(/\/\*[\s\S]*?\*\//g, ''); } + +/** + * Load a namespaced section from config.jsonc. + * @param {string} section - Top-level key (e.g. 'perfRegression', 'memLeaks') + * @returns {Record} + */ +function loadConfig(section) { + const raw = fs.readFileSync(path.join(__dirname, '..', 'config.jsonc'), 'utf-8'); + const config = JSON.parse(stripJsoncComments(raw)); + return config[section] ?? {}; +} + +// -- Electron path resolution ------------------------------------------------ + +/** + * Derive the VS Code repo root from an Electron executable path. + * Dev builds live at `/.build/electron//`, so we walk up + * from the path to find the directory containing `.build`. + * Returns `undefined` if the path doesn't look like a dev build. + * @param {string} electronPath + * @returns {string | undefined} + */ +function getRepoRoot(electronPath) { + const buildIdx = electronPath.indexOf(`${path.sep}.build${path.sep}`); + if (buildIdx === -1) { + // Also check for posix separators (path may be user-supplied) + const posixIdx = electronPath.indexOf('/.build/'); + if (posixIdx === -1) { return undefined; } + return electronPath.slice(0, posixIdx); + } + return electronPath.slice(0, buildIdx); +} + +function getElectronPath() { + const product = require(path.join(ROOT, 'product.json')); + if (process.platform === 'darwin') { + return path.join(ROOT, '.build', 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', product.nameShort); + } else if (process.platform === 'linux') { + return path.join(ROOT, '.build', 'electron', product.applicationName); + } else { + return path.join(ROOT, '.build', 'electron', `${product.nameShort}.exe`); + } +} + +/** + * Returns true if the string looks like a VS Code version or commit hash + * rather than a file path. + * @param {string} value + */ +function isVersionString(value) { + if (value === 'insiders' || value === 'stable') { return true; } + if (/^\d+\.\d+\.\d+/.test(value)) { return true; } + if (/^[0-9a-f]{7,40}$/.test(value)) { return true; } + return false; +} + +/** + * Get the built-in extensions directory for a VS Code executable. + * @param {string} exePath + * @returns {string | undefined} + */ +function getBuiltinExtensionsDir(exePath) { + if (process.platform === 'darwin') { + const appDir = exePath.split('/Contents/')[0]; + return path.join(appDir, 'Contents', 'Resources', 'app', 'extensions'); + } else if (process.platform === 'linux') { + return path.join(path.dirname(exePath), 'resources', 'app', 'extensions'); + } else { + return path.join(path.dirname(exePath), 'resources', 'app', 'extensions'); + } +} + +/** + * Resolve a build arg to an executable path. + * Version strings are downloaded via @vscode/test-electron. + * @param {string | undefined} buildArg + * @returns {Promise} + */ +async function resolveBuild(buildArg) { + if (!buildArg) { + return getElectronPath(); + } + if (isVersionString(buildArg)) { + console.log(`[chat-simulation] Downloading VS Code ${buildArg}...`); + const { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } = require('@vscode/test-electron'); + const exePath = await downloadAndUnzipVSCode(buildArg); + console.log(`[chat-simulation] Downloaded: ${exePath}`); + + // Check if copilot is already bundled as a built-in extension + // (recent Insiders/Stable builds ship it in the app's extensions/ dir). + const builtinExtDir = getBuiltinExtensionsDir(exePath); + const hasCopilotBuiltin = builtinExtDir && fs.existsSync(builtinExtDir) + && fs.readdirSync(builtinExtDir).some(e => e === 'copilot'); + + if (hasCopilotBuiltin) { + console.log(`[chat-simulation] Copilot is bundled as a built-in extension`); + } else { + // Install copilot-chat from the marketplace into our shared + // extensions dir so it's available when we launch with + // --extensions-dir=DATA_DIR/extensions. + const extDir = path.join(DATA_DIR, 'extensions'); + fs.mkdirSync(extDir, { recursive: true }); + const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(exePath); + const extId = 'GitHub.copilot-chat'; + console.log(`[chat-simulation] Installing ${extId} into ${extDir}...`); + const { spawnSync } = require('child_process'); + const result = spawnSync(cli, [...cliArgs, '--extensions-dir', extDir, '--install-extension', extId], { + encoding: 'utf-8', + stdio: 'pipe', + shell: process.platform === 'win32', + timeout: 120_000, + }); + if (result.status !== 0) { + console.warn(`[chat-simulation] Extension install exited with ${result.status}: ${(result.stderr || '').substring(0, 500)}`); + } else { + console.log(`[chat-simulation] ${extId} installed`); + } + } + + return exePath; + } + return path.resolve(buildArg); +} + +// -- Storage pre-seeding ----------------------------------------------------- + +/** + * Pre-seed the VS Code storage database to prevent the + * BuiltinChatExtensionEnablementMigration from disabling the copilot + * extension on fresh user data directories. + * + * Requires `sqlite3` on PATH (pre-installed on macOS and Ubuntu). + * @param {string} userDataDir + */ +function preseedStorage(userDataDir) { + const globalStorageDir = path.join(userDataDir, 'User', 'globalStorage'); + fs.mkdirSync(globalStorageDir, { recursive: true }); + const dbPath = path.join(globalStorageDir, 'state.vscdb'); + const sql = [ + 'CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB);', + 'INSERT INTO ItemTable (key, value) VALUES (\'builtinChatExtensionEnablementMigration\', \'true\');', + 'INSERT INTO ItemTable (key, value) VALUES (\'chat.tools.global.autoApprove.optIn\', \'true\');', + ].join(' '); + execFileSync('sqlite3', [dbPath, sql]); +} + +// -- Launch helpers ---------------------------------------------------------- + +/** + * Build the environment variables for launching VS Code with the mock server. + * @param {{ url: string }} mockServer + * @param {{ isDevBuild?: boolean }} [opts] + * @returns {Record} + */ +function buildEnv(mockServer, { isDevBuild = true } = {}) { + /** @type {Record} */ + const env = { + ...process.env, + ELECTRON_ENABLE_LOGGING: '1', + IS_SCENARIO_AUTOMATION: '1', + GITHUB_PAT: 'perf-benchmark-fake-pat', + VSCODE_COPILOT_CHAT_TOKEN: Buffer.from(JSON.stringify({ + token: 'perf-benchmark-fake-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_in: 1800, + sku: 'free_limited_copilot', + individual: true, + isNoAuthUser: true, + copilot_plan: 'free', + organization_login_list: [], + endpoints: { api: mockServer.url, proxy: mockServer.url }, + })).toString('base64'), + }; + // Dev-only flags — these tell Electron to load the app from source (out/) + // instead of the packaged app. Setting them on a stable build causes it + // to fail to show a window. + if (isDevBuild) { + env.NODE_ENV = 'development'; + env.VSCODE_DEV = '1'; + env.VSCODE_CLI = '1'; + } + return env; +} + +/** + * Build the default VS Code launch args. + * @param {string} userDataDir + * @param {string} extDir + * @param {string} logsDir + * @returns {string[]} + */ +function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostInspectPort = 0, traceFile = '', appRoot = ROOT } = {}) { + // Chromium switches must come BEFORE the app path (ROOT) — Chromium + // only processes switches that precede the first non-switch argument. + const chromiumFlags = []; + if (traceFile) { + chromiumFlags.push(`--enable-tracing=v8.gc,devtools.timeline,blink.user_timing`); + chromiumFlags.push(`--trace-startup-file=${traceFile}`); + chromiumFlags.push(`--enable-tracing-format=json`); + } + const args = [ + ...chromiumFlags, + appRoot, + '--skip-release-notes', + '--skip-welcome', + '--disable-telemetry', + '--disable-updates', + '--disable-workspace-trust', + `--user-data-dir=${userDataDir}`, + `--extensions-dir=${extDir}`, + `--logsPath=${logsDir}`, + '--enable-smoke-test-driver', + '--disable-extensions', + ]; + // vscode-api-tests only exists in the dev build + if (isDevBuild) { + args.push('--disable-extension=vscode.vscode-api-tests'); + } + if (process.platform !== 'darwin') { + args.push('--disable-gpu'); + } + if (process.env.CI && process.platform === 'linux') { + args.push('--no-sandbox'); + } + // Enable extension host inspector for profiling/heap snapshots + if (extHostInspectPort > 0) { + args.push(`--inspect-extensions=${extHostInspectPort}`); + } + return args; +} + +/** + * Write VS Code settings that point the copilot extension at the mock server. + * @param {string} userDataDir + * @param {{ url: string }} mockServer + * @param {Record} [overrides] + */ +function writeSettings(userDataDir, mockServer, overrides) { + const settingsDir = path.join(userDataDir, 'User'); + fs.mkdirSync(settingsDir, { recursive: true }); + fs.writeFileSync(path.join(settingsDir, 'settings.json'), JSON.stringify({ + 'github.copilot.advanced.debug.overrideProxyUrl': mockServer.url, + 'github.copilot.advanced.debug.overrideCapiUrl': mockServer.url, + 'chat.allowAnonymousAccess': true, + // Disable MCP servers — they start async and add unpredictable + // delay that pollutes perf measurements. + 'chat.mcp.discovery.enabled': false, + 'chat.mcp.enabled': false, + 'github.copilot.chat.githubMcpServer.enabled': false, + 'github.copilot.chat.cli.mcp.enabled': false, + // Auto-approve all tool invocations (YOLO mode) so tool call + // scenarios don't block on confirmation dialogs. + 'chat.tools.global.autoApprove': true, + ...overrides, + }, null, '\t')); +} + +/** + * Prepare a fresh run directory (clean, create, preseed, write settings). + * @param {string} runId + * @param {{ url: string }} mockServer + * @param {Record} [settingsOverrides] + * @returns {{ userDataDir: string, extDir: string, logsDir: string }} + */ +function prepareRunDir(runId, mockServer, settingsOverrides) { + const tmpBase = path.join(os.tmpdir(), 'vscode-chat-simulation'); + const userDataDir = path.join(tmpBase, `run-${runId}`); + const extDir = path.join(DATA_DIR, 'extensions'); + const logsDir = path.join(tmpBase, 'logs', `run-${runId}`); + // Retry rmSync to handle ENOTEMPTY race conditions from Electron cache locks + for (let attempt = 0; attempt < 3; attempt++) { + try { + fs.rmSync(userDataDir, { recursive: true, force: true }); + break; + } catch (err) { + const error = /** @type {NodeJS.ErrnoException} */ (err); + if (attempt < 2 && error.code === 'ENOTEMPTY') { + require('child_process').execSync(`sleep 0.5`); + } else { + throw error; + } + } + } + fs.mkdirSync(userDataDir, { recursive: true }); + fs.mkdirSync(extDir, { recursive: true }); + fs.mkdirSync(logsDir, { recursive: true }); + preseedStorage(userDataDir); + writeSettings(userDataDir, mockServer, settingsOverrides); + return { userDataDir, extDir, logsDir }; +} + +// -- VS Code launch via CDP -------------------------------------------------- + +// -- Extension host inspector ------------------------------------------------ + +/** @type {number} */ +let nextExtHostPort = 29222; + +/** @returns {number} */ +function getNextExtHostInspectPort() { + return nextExtHostPort++; +} + +/** + * Connect to the extension host's Node inspector via WebSocket. + * The extension host must be started with `--inspect-extensions=`. + * + * @param {number} port + * @param {{ verbose?: boolean, timeoutMs?: number }} [opts] + * @returns {Promise<{ send: (method: string, params?: any) => Promise, on: (event: string, listener: (params: any) => void) => void, close: () => void, port: number }>} + */ +async function connectToExtHostInspector(port, opts = {}) { + const { verbose = false, timeoutMs = 30_000 } = opts; + + // Wait for the inspector endpoint to be available + const deadline = Date.now() + timeoutMs; + /** @type {any} */ + let wsUrl; + while (Date.now() < deadline) { + try { + const targets = await getJson(`http://127.0.0.1:${port}/json`); + if (targets.length > 0 && targets[0].webSocketDebuggerUrl) { + wsUrl = targets[0].webSocketDebuggerUrl; + break; + } + } catch { } + await new Promise(r => setTimeout(r, 500)); + } + if (!wsUrl) { + throw new Error(`Timed out waiting for extension host inspector on port ${port}`); + } + + if (verbose) { + console.log(` [ext-host] Connected to inspector: ${wsUrl}`); + } + + const WebSocket = require('ws'); + const ws = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + ws.once('open', resolve); + ws.once('error', reject); + }); + + let msgId = 1; + /** @type {Map void, reject: (e: Error) => void }>} */ + const pending = new Map(); + /** @type {Map void)[]>} */ + const eventListeners = new Map(); + + ws.on('message', (/** @type {Buffer} */ data) => { + const msg = JSON.parse(data.toString()); + if (msg.id !== undefined) { + const p = pending.get(msg.id); + if (p) { + pending.delete(msg.id); + if (msg.error) { p.reject(new Error(msg.error.message)); } + else { p.resolve(msg.result); } + } + } else if (msg.method) { + const listeners = eventListeners.get(msg.method) || []; + for (const listener of listeners) { listener(msg.params); } + } + }); + + return { + port, + /** + * @param {string} method + * @param {any} [params] + * @returns {Promise} + */ + send(method, params) { + return new Promise((resolve, reject) => { + const id = msgId++; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error(`Inspector call timed out: ${method}`)); + } + }, 30_000); + }); + }, + /** + * @param {string} event + * @param {(params: any) => void} listener + */ + on(event, listener) { + const list = eventListeners.get(event) || []; + list.push(listener); + eventListeners.set(event, list); + }, + close() { + ws.close(); + }, + }; +} + +/** + * Fetch JSON from a URL. Used to probe the CDP endpoint. + * @param {string} url + * @returns {Promise} + */ +function getJson(url) { + return new Promise((resolve, reject) => { + http.get(url, res => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { resolve(JSON.parse(data)); } + catch { reject(new Error(`Invalid JSON from ${url}`)); } + }); + }).on('error', reject); + }); +} + +/** + * Wait until VS Code exposes its CDP endpoint. + * @param {number} port + * @param {number} timeoutMs + * @returns {Promise} + */ +async function waitForCDP(port, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await getJson(`http://127.0.0.1:${port}/json/version`); + return; + } catch { + await new Promise(r => setTimeout(r, 500)); + } + } + throw new Error(`Timed out waiting for CDP on port ${port}`); +} + +/** + * Find the workbench page among all CDP pages. + * For dev builds this checks for `globalThis.driver` (smoke-test driver). + * For stable builds it checks for `.monaco-workbench` in the DOM. + * @param {import('playwright').Browser} browser + * @param {number} timeoutMs + * @returns {Promise} + */ +async function findWorkbenchPage(browser, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const pages = browser.contexts().flatMap(ctx => ctx.pages()); + for (const page of pages) { + const hasWorkbench = await page.evaluate(() => + // @ts-ignore + !!globalThis.driver?.whenWorkbenchRestored || !!document.querySelector('.monaco-workbench') + ).catch(() => false); + if (hasWorkbench) { + return page; + } + } + await new Promise(r => setTimeout(r, 500)); + } + throw new Error('Timed out waiting for the workbench page'); +} + +/** @type {number} */ +let nextPort = 19222; + +/** + * Launch VS Code via child_process and connect via CDP. + * Works with dev builds, insiders, and stable releases. + * + * @param {string} executable - Path to the VS Code executable (Electron binary or CLI) + * @param {string[]} launchArgs - Arguments to pass to the executable + * @param {Record} env - Environment variables + * @param {{ verbose?: boolean }} [opts] + * @returns {Promise<{ page: import('playwright').Page, browser: import('playwright').Browser, close: () => Promise }>} + */ +async function launchVSCode(executable, launchArgs, env, opts = {}) { + const { chromium } = require('playwright'); + const port = nextPort++; + + const args = [`--remote-debugging-port=${port}`, ...launchArgs]; + const isShell = process.platform === 'win32'; + + if (opts.verbose) { + console.log(` [launch] ${executable} ${args.slice(0, 3).join(' ')} ... (port ${port})`); + } + + const child = spawn(executable, args, { + cwd: ROOT, + env, + shell: isShell, + stdio: opts.verbose ? 'inherit' : ['ignore', 'ignore', 'ignore'], + }); + + // Track early exit + let exitError = /** @type {Error | null} */ (null); + child.once('exit', (code, signal) => { + if (!exitError) { + exitError = new Error(`VS Code exited before CDP connected (code=${code} signal=${signal})`); + } + }); + + // Wait for CDP + try { + await waitForCDP(port); + } catch (e) { + if (exitError) { throw exitError; } + throw e; + } + + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); + const page = await findWorkbenchPage(browser); + + return { + page, + browser, + close: async () => { + // Trigger app.quit() so Chromium flushes trace buffers and + // writes --trace-startup-file. Using Cmd+Q / Alt+F4 triggers + // the full Electron quit lifecycle including trace flush. + // window.close() only closes the BrowserWindow without + // triggering app-level quit. + try { + const quitKey = process.platform === 'darwin' ? 'Meta+KeyQ' : 'Alt+F4'; + await page.keyboard.press(quitKey); + } catch { + // Page may already be closed + } + const pid = child.pid; + // Wait for graceful exit (up to 30s for trace flush) + await new Promise(resolve => { + const timer = setTimeout(() => { + if (pid) { + try { execSync(`pkill -9 -P ${pid}`, { stdio: 'ignore' }); } + catch { } + } + child.kill('SIGKILL'); + resolve(undefined); + }, 30_000); + child.once('exit', () => { clearTimeout(timer); resolve(undefined); }); + }); + // Disconnect CDP after the process has exited + await browser.close().catch(() => { }); + // Kill crashpad handler — it self-daemonizes and outlives the + // parent. Wait briefly for it to detach, then kill by pattern. + await new Promise(r => setTimeout(r, 500)); + try { execSync('pkill -9 -f crashpad_handler.*vscode-chat-simulation', { stdio: 'ignore' }); } + catch { } + }, + }; +} + +// -- Statistics -------------------------------------------------------------- + +/** + * @param {number[]} values + */ +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** + * Remove outliers using IQR method. + * @param {number[]} values + * @returns {number[]} + */ +function removeOutliers(values) { + if (values.length < 4) { return values; } + const sorted = [...values].sort((a, b) => a - b); + const q1 = sorted[Math.floor(sorted.length * 0.25)]; + const q3 = sorted[Math.floor(sorted.length * 0.75)]; + const iqr = q3 - q1; + const lo = q1 - 1.5 * iqr; + const hi = q3 + 1.5 * iqr; + return sorted.filter(v => v >= lo && v <= hi); +} + +/** + * Regularized incomplete beta function I_x(a, b) via continued fraction. + * Used for computing t-distribution CDF / p-values. + * @param {number} x + * @param {number} a + * @param {number} b + * @returns {number} + */ +function betaIncomplete(x, a, b) { + if (x <= 0) { return 0; } + if (x >= 1) { return 1; } + // Use symmetry relation when x > (a+1)/(a+b+2) for better convergence + if (x > (a + 1) / (a + b + 2)) { + return 1 - betaIncomplete(1 - x, b, a); + } + // Log-beta via Stirling: lnBeta(a,b) = lnGamma(a)+lnGamma(b)-lnGamma(a+b) + const lnBeta = lnGamma(a) + lnGamma(b) - lnGamma(a + b); + const front = Math.exp(Math.log(x) * a + Math.log(1 - x) * b - lnBeta) / a; + // Lentz's continued fraction + const maxIter = 200; + const eps = 1e-14; + let c = 1, d = 1 - (a + b) * x / (a + 1); + if (Math.abs(d) < eps) { d = eps; } + d = 1 / d; + let result = d; + for (let m = 1; m <= maxIter; m++) { + // Even step + let num = m * (b - m) * x / ((a + 2 * m - 1) * (a + 2 * m)); + d = 1 + num * d; if (Math.abs(d) < eps) { d = eps; } d = 1 / d; + c = 1 + num / c; if (Math.abs(c) < eps) { c = eps; } + result *= d * c; + // Odd step + num = -(a + m) * (a + b + m) * x / ((a + 2 * m) * (a + 2 * m + 1)); + d = 1 + num * d; if (Math.abs(d) < eps) { d = eps; } d = 1 / d; + c = 1 + num / c; if (Math.abs(c) < eps) { c = eps; } + const delta = d * c; + result *= delta; + if (Math.abs(delta - 1) < eps) { break; } + } + return front * result; +} + +/** + * Log-gamma via Lanczos approximation. + * @param {number} z + * @returns {number} + */ +function lnGamma(z) { + const g = 7; + const coef = [0.99999999999980993, 676.5203681218851, -1259.1392167224028, + 771.32342877765313, -176.61502916214059, 12.507343278686905, + -0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7]; + if (z < 0.5) { + return Math.log(Math.PI / Math.sin(Math.PI * z)) - lnGamma(1 - z); + } + z -= 1; + let x = coef[0]; + for (let i = 1; i < g + 2; i++) { x += coef[i] / (z + i); } + const t = z + g + 0.5; + return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x); +} + +/** + * Two-tailed p-value from t-distribution. + * @param {number} t - t-statistic + * @param {number} df - degrees of freedom + * @returns {number} + */ +function tDistPValue(t, df) { + const x = df / (df + t * t); + return betaIncomplete(x, df / 2, 0.5); +} + +/** + * Welch's t-test for two independent samples (unequal variance). + * @param {number[]} a - Sample 1 (e.g., baseline values) + * @param {number[]} b - Sample 2 (e.g., current values) + * @returns {{ t: number, df: number, pValue: number, significant: boolean, confidence: string } | null} + */ +function welchTTest(a, b) { + if (a.length < 2 || b.length < 2) { return null; } + const meanA = a.reduce((s, v) => s + v, 0) / a.length; + const meanB = b.reduce((s, v) => s + v, 0) / b.length; + const varA = a.reduce((s, v) => s + (v - meanA) ** 2, 0) / (a.length - 1); + const varB = b.reduce((s, v) => s + (v - meanB) ** 2, 0) / (b.length - 1); + const seA = varA / a.length; + const seB = varB / b.length; + const seDiff = Math.sqrt(seA + seB); + if (seDiff === 0) { return null; } + const t = (meanB - meanA) / seDiff; + // Welch-Satterthwaite degrees of freedom + const df = (seA + seB) ** 2 / ((seA ** 2) / (a.length - 1) + (seB ** 2) / (b.length - 1)); + const pValue = tDistPValue(t, df); + const significant = pValue < 0.05; + let confidence; + if (pValue < 0.01) { confidence = 'high'; } + else if (pValue < 0.05) { confidence = 'medium'; } + else if (pValue < 0.1) { confidence = 'low'; } + else { confidence = 'none'; } + return { t: Math.round(t * 100) / 100, df: Math.round(df * 10) / 10, pValue: Math.round(pValue * 1000) / 1000, significant, confidence }; +} + +/** + * Compute robust stats for a metric array. + * @param {number[]} raw + */ +function robustStats(raw) { + const valid = raw.filter(v => v >= 0); + if (valid.length === 0) { return null; } + const cleaned = removeOutliers(valid); + if (cleaned.length === 0) { return null; } + const sorted = [...cleaned].sort((a, b) => a - b); + const med = median(sorted); + const p95 = sorted[Math.min(Math.floor(sorted.length * 0.95), sorted.length - 1)]; + const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length; + const variance = sorted.reduce((a, b) => a + (b - mean) ** 2, 0) / sorted.length; + const stddev = Math.sqrt(variance); + const cv = mean > 0 ? stddev / mean : 0; + return { + median: Math.round(med * 100) / 100, + p95: Math.round(p95 * 100) / 100, + min: sorted[0], + max: sorted[sorted.length - 1], + mean: Math.round(mean * 100) / 100, + stddev: Math.round(stddev * 100) / 100, + cv: Math.round(cv * 1000) / 1000, + n: sorted.length, + nOutliers: valid.length - cleaned.length, + }; +} + +/** + * Simple linear regression slope (y per unit x). + * @param {number[]} values + */ +function linearRegressionSlope(values) { + const n = values.length; + if (n < 2) { return 0; } + let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; + for (let i = 0; i < n; i++) { + sumX += i; + sumY += values[i]; + sumXY += i * values[i]; + sumX2 += i * i; + } + return (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); +} + +/** + * Format a single metric line for console output. + * @param {number[]} values + * @param {string} label + * @param {string} unit + */ +function summarize(values, label, unit) { + const s = robustStats(values); + if (!s) { return ` ${label}: (no data)`; } + const cv = s.cv > 0.15 ? ` cv=${(s.cv * 100).toFixed(0)}%⚠` : ` cv=${(s.cv * 100).toFixed(0)}%`; + const outliers = s.nOutliers > 0 ? ` (${s.nOutliers} outlier${s.nOutliers > 1 ? 's' : ''} removed)` : ''; + return ` ${label}: median=${s.median}${unit}, p95=${s.p95}${unit},${cv}${outliers} [n=${s.n}]`; +} + +/** + * Compute duration between two chat perf marks. + * @param {Array<{name: string, startTime: number}>} marks + * @param {string} from + * @param {string} to + */ +function markDuration(marks, from, to) { + const fromMark = marks.find(m => m.name.endsWith('/' + from)); + const toMark = marks.find(m => m.name.endsWith('/' + to)); + if (fromMark && toMark) { + return toMark.startTime - fromMark.startTime; + } + return -1; +} + +/** @type {Array<[string, string, string]>} */ +const METRIC_DEFS = [ + ['timeToFirstToken', 'timing', 'ms'], + ['timeToComplete', 'timing', 'ms'], + ['timeToRenderComplete', 'timing', 'ms'], + ['timeToUIUpdated', 'timing', 'ms'], + ['instructionCollectionTime', 'timing', 'ms'], + ['agentInvokeTime', 'timing', 'ms'], + ['heapDelta', 'memory', 'MB'], + ['heapDeltaPostGC', 'memory', 'MB'], + ['gcDurationMs', 'memory', 'ms'], + ['layoutCount', 'rendering', ''], + ['layoutDurationMs', 'rendering', 'ms'], + ['recalcStyleCount', 'rendering', ''], + ['forcedReflowCount', 'rendering', ''], + ['longTaskCount', 'rendering', ''], + ['longAnimationFrameCount', 'rendering', ''], + ['longAnimationFrameTotalMs', 'rendering', 'ms'], + ['frameCount', 'rendering', ''], + ['compositeLayers', 'rendering', ''], + ['paintCount', 'rendering', ''], + ['extHostHeapUsedBefore', 'extHost', 'MB'], + ['extHostHeapUsedAfter', 'extHost', 'MB'], + ['extHostHeapDelta', 'extHost', 'MB'], + ['extHostHeapDeltaPostGC', 'extHost', 'MB'], +]; + +module.exports = { + ROOT, + DATA_DIR, + METRIC_DEFS, + loadConfig, + getElectronPath, + getRepoRoot, + isVersionString, + resolveBuild, + preseedStorage, + buildEnv, + buildArgs, + writeSettings, + prepareRunDir, + median, + removeOutliers, + robustStats, + welchTTest, + linearRegressionSlope, + summarize, + markDuration, + launchVSCode, + getNextExtHostInspectPort, + connectToExtHostInspector, +}; diff --git a/scripts/chat-simulation/config.jsonc b/scripts/chat-simulation/config.jsonc new file mode 100644 index 0000000000000..dfcbdc3d97a77 --- /dev/null +++ b/scripts/chat-simulation/config.jsonc @@ -0,0 +1,37 @@ +{ + "perfRegression": { + // VS Code version, "insiders", or a commit hash (7-40 hex chars) + "baselineBuild": "1.116.0", + + // Number of benchmark iterations per scenario + "runsPerScenario": 5, + + // Default fraction above baseline that triggers a regression (0.2 = 20%). + // Per-metric overrides below take precedence when set. + "regressionThreshold": 0.2, + + // Per-metric regression thresholds. + // - A plain number (0-1) is a fraction, e.g. 0.2 = 20% above baseline. + // - A string ending in the metric's unit (e.g. "100ms") is an absolute delta. + // Metrics not listed here use regressionThreshold above. + "metricThresholds": { + "timeToFirstToken": "100ms", + "timeToComplete": 0.2, + "layoutCount": 0.2, + "recalcStyleCount": 0.2, + "forcedReflowCount": 0.2, + "longTaskCount": 0.2, + "longAnimationFrameCount": 0.2 + } + }, + "memLeaks": { + // Number of open→work→reset cycles + "iterations": 3, + + // Max acceptable total residual heap growth in MB. + // Each iteration cycles through ALL scenarios (text, code blocks, + // tool calls, thinking, terminal, multi-turn, etc.), so this needs + // to account for V8 internal caches that aren't immediately reclaimed. + "leakThresholdMB": 10 + } +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_arrays.ts b/scripts/chat-simulation/fixtures/_chatperf_arrays.ts new file mode 100644 index 0000000000000..6a871b43e0ce3 --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_arrays.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/arrays.ts for stable perf testing. + */ + +export function coalesce(array: ReadonlyArray): T[] { + return array.filter((e): e is T => e !== undefined && e !== null); +} + +export function groupBy(data: ReadonlyArray, groupFn: (element: T) => string): { [key: string]: T[] } { + const result: { [key: string]: T[] } = {}; + for (const element of data) { + const key = groupFn(element); + (result[key] ??= []).push(element); + } + return result; +} + +export function distinct(array: ReadonlyArray, keyFn: (t: T) => any = t => t): T[] { + const seen = new Set(); + return array.filter(element => { + const key = keyFn(element); + if (seen.has(key)) { return false; } + seen.add(key); + return true; + }); +} + +export function firstOrDefault(array: ReadonlyArray): T | undefined; +export function firstOrDefault(array: ReadonlyArray, defaultValue: T): T; +export function firstOrDefault(array: ReadonlyArray, defaultValue?: T): T | undefined { + return array.length > 0 ? array[0] : defaultValue; +} + +export function lastOrDefault(array: ReadonlyArray): T | undefined; +export function lastOrDefault(array: ReadonlyArray, defaultValue: T): T; +export function lastOrDefault(array: ReadonlyArray, defaultValue?: T): T | undefined { + return array.length > 0 ? array[array.length - 1] : defaultValue; +} + +export function binarySearch(array: ReadonlyArray, key: T, comparator: (a: T, b: T) => number): number { + let low = 0; + let high = array.length - 1; + while (low <= high) { + const mid = ((low + high) / 2) | 0; + const comp = comparator(array[mid], key); + if (comp < 0) { low = mid + 1; } + else if (comp > 0) { high = mid - 1; } + else { return mid; } + } + return -(low + 1); +} + +export function insertSorted(array: T[], element: T, comparator: (a: T, b: T) => number): void { + const idx = binarySearch(array, element, comparator); + const insertIdx = idx < 0 ? ~idx : idx; + array.splice(insertIdx, 0, element); +} + +export function flatten(arr: T[][]): T[] { + return ([] as T[]).concat(...arr); +} + +export function range(to: number): number[]; +export function range(from: number, to: number): number[]; +export function range(arg: number, to?: number): number[] { + const from = to !== undefined ? arg : 0; + const end = to !== undefined ? to : arg; + const result: number[] = []; + for (let i = from; i < end; i++) { result.push(i); } + return result; +} + +export function tail(array: T[]): [T[], T] { + if (array.length === 0) { throw new Error('Invalid tail call'); } + return [array.slice(0, array.length - 1), array[array.length - 1]]; +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_async.ts b/scripts/chat-simulation/fixtures/_chatperf_async.ts new file mode 100644 index 0000000000000..7964eea892ece --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_async.ts @@ -0,0 +1,132 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/async.ts for stable perf testing. + */ + +import { IDisposable } from './lifecycle'; +import { CancellationError } from './errors'; + +export class Throttler { + private activePromise: Promise | null = null; + private queuedPromiseFactory: (() => Promise) | null = null; + + queue(promiseFactory: () => Promise): Promise { + if (this.activePromise) { + this.queuedPromiseFactory = promiseFactory; + return this.activePromise as Promise; + } + this.activePromise = promiseFactory(); + return this.activePromise.finally(() => { + this.activePromise = null; + if (this.queuedPromiseFactory) { + const factory = this.queuedPromiseFactory; + this.queuedPromiseFactory = null; + return this.queue(factory); + } + }); + } +} + +export class Delayer implements IDisposable { + private timeout: any; + private task: (() => T | Promise) | null = null; + + constructor(public defaultDelay: number) { } + + trigger(task: () => T | Promise, delay: number = this.defaultDelay): Promise { + this.task = task; + this.cancelTimeout(); + return new Promise((resolve, reject) => { + this.timeout = setTimeout(() => { + this.timeout = null; + try { resolve(this.task!()); } catch (e) { reject(e); } + this.task = null; + }, delay); + }); + } + + private cancelTimeout(): void { + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + } + + dispose(): void { + this.cancelTimeout(); + } +} + +export class RunOnceScheduler implements IDisposable { + private runner: (() => void) | null; + private timeout: any; + + constructor(runner: () => void, private delay: number) { + this.runner = runner; + } + + schedule(delay = this.delay): void { + this.cancel(); + this.timeout = setTimeout(() => { + this.timeout = null; + this.runner?.(); + }, delay); + } + + cancel(): void { + if (this.timeout !== null) { + clearTimeout(this.timeout); + this.timeout = null; + } + } + + isScheduled(): boolean { return this.timeout !== null; } + + dispose(): void { + this.cancel(); + this.runner = null; + } +} + +export class Queue { + private readonly queue: Array<() => Promise> = []; + private running = false; + + async enqueue(factory: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.queue.push(() => factory().then(resolve, reject)); + if (!this.running) { this.processQueue(); } + }); + } + + private async processQueue(): Promise { + this.running = true; + while (this.queue.length > 0) { + const task = this.queue.shift()!; + await task(); + } + this.running = false; + } + + get size(): number { return this.queue.length; } +} + +export function timeout(millis: number): Promise { + return new Promise(resolve => setTimeout(resolve, millis)); +} + +export async function retry(task: () => Promise, delay: number, retries: number): Promise { + let lastError: Error | undefined; + for (let i = 0; i < retries; i++) { + try { return await task(); } + catch (error) { lastError = error as Error; await timeout(delay); } + } + throw lastError; +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_errors.ts b/scripts/chat-simulation/fixtures/_chatperf_errors.ts new file mode 100644 index 0000000000000..0446dbb79a69f --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_errors.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/errors.ts for stable perf testing. + */ + +export interface ErrorListenerCallback { + (error: any): void; +} + +export interface ErrorListenerUnbind { + (): void; +} + +const _errorListeners: ErrorListenerCallback[] = []; + +export function setUnexpectedErrorHandler(handler: ErrorListenerCallback): void { + _errorListeners.length = 0; + _errorListeners.push(handler); +} + +export function onUnexpectedError(e: any): void { + if (!isCancellationError(e)) { + for (const listener of _errorListeners) { + try { listener(e); } catch { } + } + } +} + +export function onUnexpectedExternalError(e: any): void { + if (!isCancellationError(e)) { + for (const listener of _errorListeners) { + try { listener(e); } catch { } + } + } +} + +export function transformErrorForSerialization(error: any): any { + if (error instanceof Error) { + const { name, message, stack } = error; + return { $isError: true, name, message, stack }; + } + return error; +} + +const canceledName = 'Canceled'; + +export function isCancellationError(error: any): boolean { + if (error instanceof CancellationError) { return true; } + return error instanceof Error && error.name === canceledName && error.message === canceledName; +} + +export class CancellationError extends Error { + constructor() { + super(canceledName); + this.name = this.message; + } +} + +export class NotSupportedError extends Error { + constructor(message?: string) { + super(message || 'NotSupported'); + } +} + +export class NotImplementedError extends Error { + constructor(message?: string) { + super(message || 'NotImplemented'); + } +} + +export class IllegalArgumentError extends Error { + constructor(message?: string) { + super(message || 'Illegal argument'); + } +} + +export class BugIndicatingError extends Error { + constructor(message?: string) { + super(message || 'Bug Indicating Error'); + } +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_event.ts b/scripts/chat-simulation/fixtures/_chatperf_event.ts new file mode 100644 index 0000000000000..6186e9e7042d9 --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_event.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/event.ts for stable perf testing. + */ + +import { IDisposable, DisposableStore } from './lifecycle'; + +export interface Event { + (listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable; +} + +export namespace Event { + export const None: Event = () => ({ dispose() { } }); + + export function once(event: Event): Event { + return (listener, thisArgs?, disposables?) => { + let didFire = false; + const result = event(e => { + if (didFire) { return; } + didFire = true; + return listener.call(thisArgs, e); + }, null, disposables); + if (didFire) { result.dispose(); } + return result; + }; + } + + export function map(event: Event, map: (i: I) => O): Event { + return (listener, thisArgs?, disposables?) => + event(i => listener.call(thisArgs, map(i)), null, disposables); + } + + export function filter(event: Event, filter: (e: T) => boolean): Event { + return (listener, thisArgs?, disposables?) => + event(e => filter(e) && listener.call(thisArgs, e), null, disposables); + } + + export function debounce(event: Event, merge: (last: T | undefined, e: T) => T, delay: number = 100): Event { + let subscription: IDisposable; + let output: T | undefined; + let handle: any; + return (listener, thisArgs?, disposables?) => { + subscription = event(cur => { + output = merge(output, cur); + clearTimeout(handle); + handle = setTimeout(() => { + const e = output!; + output = undefined; + listener.call(thisArgs, e); + }, delay); + }); + return { dispose() { subscription.dispose(); clearTimeout(handle); } }; + }; + } +} + +export class Emitter { + private readonly _listeners = new Set<(e: T) => void>(); + private _disposed = false; + + readonly event: Event = (listener: (e: T) => void) => { + if (this._disposed) { return { dispose() { } }; } + this._listeners.add(listener); + return { + dispose: () => { this._listeners.delete(listener); } + }; + }; + + fire(event: T): void { + if (this._disposed) { return; } + for (const listener of [...this._listeners]) { + try { listener(event); } catch { } + } + } + + dispose(): void { + if (this._disposed) { return; } + this._disposed = true; + this._listeners.clear(); + } + + get hasListeners(): boolean { return this._listeners.size > 0; } +} + +export class PauseableEmitter extends Emitter { + private _isPaused = false; + private _queue: T[] = []; + + pause(): void { this._isPaused = true; } + + resume(): void { + this._isPaused = false; + while (this._queue.length > 0) { + super.fire(this._queue.shift()!); + } + } + + override fire(event: T): void { + if (this._isPaused) { this._queue.push(event); } + else { super.fire(event); } + } +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_lifecycle.ts b/scripts/chat-simulation/fixtures/_chatperf_lifecycle.ts new file mode 100644 index 0000000000000..6f1bd1a16b3c8 --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_lifecycle.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/lifecycle.ts for stable perf testing. + */ + +export interface IDisposable { + dispose(): void; +} + +export function isDisposable(thing: T): thing is T & IDisposable { + return typeof (thing as IDisposable).dispose === 'function' + && (thing as IDisposable).dispose.length === 0; +} + +export function dispose(disposable: T): T; +export function dispose(disposable: T | undefined): T | undefined; +export function dispose(disposables: T[]): T[]; +export function dispose(disposables: readonly T[]): readonly T[]; +export function dispose(arg: T | T[] | undefined): any { + if (Array.isArray(arg)) { + const errors: any[] = []; + for (const d of arg) { + try { d.dispose(); } catch (e) { errors.push(e); } + } + if (errors.length > 0) { throw new Error(`Dispose errors: ${errors.length}`); } + return arg; + } else if (arg) { + arg.dispose(); + return arg; + } +} + +export class DisposableStore implements IDisposable { + private readonly _toDispose = new Set(); + private _isDisposed = false; + + dispose(): void { + if (this._isDisposed) { return; } + this._isDisposed = true; + this.clear(); + } + + clear(): void { + if (this._toDispose.size === 0) { return; } + const iter = this._toDispose.values(); + this._toDispose.clear(); + for (const item of iter) { + try { item.dispose(); } catch { } + } + } + + add(o: T): T { + if (this._isDisposed) { + console.warn('Adding to a disposed DisposableStore'); + return o; + } + this._toDispose.add(o); + return o; + } + + get size(): number { return this._toDispose.size; } +} + +export abstract class Disposable implements IDisposable { + private readonly _store = new DisposableStore(); + + dispose(): void { + this._store.dispose(); + } + + protected _register(o: T): T { + return this._store.add(o); + } +} + +export class MutableDisposable implements IDisposable { + private _value?: T; + private _isDisposed = false; + + get value(): T | undefined { return this._isDisposed ? undefined : this._value; } + + set value(value: T | undefined) { + if (this._isDisposed || value === this._value) { return; } + this._value?.dispose(); + this._value = value; + } + + dispose(): void { + this._isDisposed = true; + this._value?.dispose(); + this._value = undefined; + } +} + +export class DisposableMap implements IDisposable { + private readonly _map = new Map(); + private _isDisposed = false; + + set(key: K, value: V): void { + const existing = this._map.get(key); + if (existing !== value) { + existing?.dispose(); + this._map.set(key, value); + } + } + + get(key: K): V | undefined { return this._map.get(key); } + + delete(key: K): void { + this._map.get(key)?.dispose(); + this._map.delete(key); + } + + dispose(): void { + if (this._isDisposed) { return; } + this._isDisposed = true; + for (const [, v] of this._map) { v.dispose(); } + this._map.clear(); + } +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_strings.ts b/scripts/chat-simulation/fixtures/_chatperf_strings.ts new file mode 100644 index 0000000000000..4c7ca7637e3bd --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_strings.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/strings.ts for stable perf testing. + */ + +export function format(value: string, ...args: any[]): string { + return value.replace(/{(\d+)}/g, (match, index) => { + const i = parseInt(index, 10); + return i >= 0 && i < args.length ? `${args[i]}` : match; + }); +} + +export function escape(value: string): string { + return value.replace(/[<>&"']/g, ch => { + switch (ch) { + case '<': return '<'; + case '>': return '>'; + case '&': return '&'; + case '"': return '"'; + case '\'': return '''; + default: return ch; + } + }); +} + +export function trim(value: string, ch: string = ' '): string { + let start = 0; + let end = value.length; + while (start < end && value[start] === ch) { start++; } + while (end > start && value[end - 1] === ch) { end--; } + return value.substring(start, end); +} + +export function equalsIgnoreCase(a: string, b: string): boolean { + return a.length === b.length && a.toLowerCase() === b.toLowerCase(); +} + +export function startsWithIgnoreCase(str: string, candidate: string): boolean { + if (str.length < candidate.length) { return false; } + return str.substring(0, candidate.length).toLowerCase() === candidate.toLowerCase(); +} + +export function commonPrefixLength(a: string, b: string): number { + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + if (a.charCodeAt(i) !== b.charCodeAt(i)) { return i; } + } + return len; +} + +export function commonSuffixLength(a: string, b: string): number { + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + if (a.charCodeAt(a.length - 1 - i) !== b.charCodeAt(b.length - 1 - i)) { return i; } + } + return len; +} + +export function splitLines(str: string): string[] { + return str.split(/\r\n|\r|\n/); +} + +export function regExpLeadsToEndlessLoop(regexp: RegExp): boolean { + if (regexp.source === '^' || regexp.source === '^$' || regexp.source === '$') { + return false; + } + return !regexp.exec('')?.length; +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_types.ts b/scripts/chat-simulation/fixtures/_chatperf_types.ts new file mode 100644 index 0000000000000..0779f182b26d3 --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_types.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/types.ts for stable perf testing. + */ + +export function isString(thing: unknown): thing is string { + return typeof thing === 'string'; +} + +export function isNumber(thing: unknown): thing is number { + return typeof thing === 'number' && !isNaN(thing); +} + +export function isBoolean(thing: unknown): thing is boolean { + return thing === true || thing === false; +} + +export function isUndefined(thing: unknown): thing is undefined { + return typeof thing === 'undefined'; +} + +export function isDefined(thing: T | undefined | null): thing is T { + return !isUndefinedOrNull(thing); +} + +export function isUndefinedOrNull(thing: unknown): thing is undefined | null { + return isUndefined(thing) || thing === null; +} + +export function isFunction(thing: unknown): thing is Function { + return typeof thing === 'function'; +} + +export function isObject(thing: unknown): thing is object { + return typeof thing === 'object' + && thing !== null + && !Array.isArray(thing) + && !(thing instanceof RegExp) + && !(thing instanceof Date); +} + +export function isArray(thing: unknown): thing is unknown[] { + return Array.isArray(thing); +} + +export function assertType(condition: unknown, type?: string): asserts condition { + if (!condition) { + throw new Error(type ? `Unexpected type, expected '${type}'` : 'Unexpected type'); + } +} + +export function assertIsDefined(thing: T | undefined | null): T { + if (isUndefinedOrNull(thing)) { + throw new Error('Assertion failed: value is undefined or null'); + } + return thing; +} + +export function assertAllDefined(t1: T1 | undefined | null, t2: T2 | undefined | null): [T1, T2] { + return [assertIsDefined(t1), assertIsDefined(t2)]; +} + +export type TypeConstraint = string | Function; + +export function validateConstraints(args: unknown[], constraints: Array): void { + const len = Math.min(args.length, constraints.length); + for (let i = 0; i < len; i++) { + validateConstraint(args[i], constraints[i]); + } +} + +export function validateConstraint(arg: unknown, constraint: TypeConstraint | undefined): void { + if (isString(constraint)) { + if (typeof arg !== constraint) { + throw new Error(`argument does not match constraint: typeof ${constraint}`); + } + } else if (isFunction(constraint)) { + try { + if (arg instanceof constraint) { return; } + } catch { } + if (!isUndefinedOrNull(arg) && (arg as any).constructor === constraint) { return; } + if (constraint.length === 1 && constraint.call(undefined, arg) === true) { return; } + throw new Error('argument does not match one of these constraints: arg instanceof constraint, arg.constructor === constraint, nor constraint(arg) === true'); + } +} diff --git a/scripts/chat-simulation/fixtures/_chatperf_uri.ts b/scripts/chat-simulation/fixtures/_chatperf_uri.ts new file mode 100644 index 0000000000000..8a67bc8065eb6 --- /dev/null +++ b/scripts/chat-simulation/fixtures/_chatperf_uri.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// perf-benchmark-marker + +/** + * Fixture for chat-simulation benchmarks. + * Simplified from src/vs/base/common/uri.ts for stable perf testing. + */ + +const _empty = ''; +const _slash = '/'; + +export class URI { + readonly scheme: string; + readonly authority: string; + readonly path: string; + readonly query: string; + readonly fragment: string; + + private constructor(scheme: string, authority: string, path: string, query: string, fragment: string) { + this.scheme = scheme; + this.authority = authority || _empty; + this.path = path || _empty; + this.query = query || _empty; + this.fragment = fragment || _empty; + } + + static file(path: string): URI { + let authority = _empty; + if (path.length >= 2 && path.charCodeAt(0) === 47 /* / */ && path.charCodeAt(1) === 47 /* / */) { + const idx = path.indexOf(_slash, 2); + if (idx === -1) { + authority = path.substring(2); + path = _slash; + } else { + authority = path.substring(2, idx); + path = path.substring(idx) || _slash; + } + } + return new URI('file', authority, path, _empty, _empty); + } + + static parse(value: string): URI { + const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\/([^/?#]*)([^?#]*)(\?[^#]*)?(#.*)?$/.exec(value); + if (!match) { return new URI(_empty, _empty, _empty, _empty, _empty); } + return new URI(match[1], match[2], match[3], match[4]?.substring(1) || _empty, match[5]?.substring(1) || _empty); + } + + with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): URI { + return new URI( + change.scheme ?? this.scheme, + change.authority ?? this.authority, + change.path ?? this.path, + change.query ?? this.query, + change.fragment ?? this.fragment, + ); + } + + toString(): string { + let result = ''; + if (this.scheme) { result += this.scheme + '://'; } + if (this.authority) { result += this.authority; } + if (this.path) { result += this.path; } + if (this.query) { result += '?' + this.query; } + if (this.fragment) { result += '#' + this.fragment; } + return result; + } + + get fsPath(): string { + return this.path; + } + + toJSON(): object { + return { + scheme: this.scheme, + authority: this.authority, + path: this.path, + query: this.query, + fragment: this.fragment, + }; + } +} diff --git a/scripts/chat-simulation/merge-ci-summary.js b/scripts/chat-simulation/merge-ci-summary.js new file mode 100644 index 0000000000000..10d9a4b60ae7d --- /dev/null +++ b/scripts/chat-simulation/merge-ci-summary.js @@ -0,0 +1,535 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +/** + * Merge per-group perf results into a single unified CI summary. + * + * Called by the CI report job after all matrix groups have finished. + * Reads results.json and baseline-*.json from each group directory, + * merges all scenarios into one combined report, and writes a single + * ci-summary.md file. + * + * Usage: + * node scripts/chat-simulation/merge-ci-summary.js \ + * --results-dir perf-results \ + * --output ci-summary.md \ + * [--leak-summary leak-results/.chat-simulation-data/ci-summary-leak.md] \ + * [--threshold 0.2] + */ + +const fs = require('fs'); +const path = require('path'); +const { welchTTest, loadConfig } = require('./common/utils'); + +// -- CLI args ---------------------------------------------------------------- + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + resultsDir: '', + output: '', + /** @type {string | undefined} */ + leakSummary: undefined, + threshold: 0.2, + /** @type {Record} */ + metricThresholds: {}, + }; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--results-dir': opts.resultsDir = args[++i]; break; + case '--output': opts.output = args[++i]; break; + case '--leak-summary': opts.leakSummary = args[++i]; break; + case '--threshold': opts.threshold = parseFloat(args[++i]); break; + case '--help': case '-h': + console.log([ + 'Merge per-group perf results into a single CI summary.', + '', + 'Options:', + ' --results-dir Directory containing perf-results-* subdirs', + ' --output Output path for ci-summary.md', + ' --leak-summary Path to ci-summary-leak.md (optional)', + ' --threshold Regression threshold fraction (default: 0.2)', + ].join('\n')); + process.exit(0); + } + } + if (!opts.resultsDir || !opts.output) { + console.error('Required: --results-dir and --output'); + process.exit(1); + } + return opts; +} + +// -- Merge logic ------------------------------------------------------------- + +/** + * Find all results.json and baseline-*.json files across group directories, + * merge scenarios into a single combined report. + * @param {string} resultsDir + */ +function mergeResults(resultsDir) { + const groupDirs = fs.readdirSync(resultsDir) + .filter(d => d.startsWith('perf-results-')) + .map(d => path.join(resultsDir, d)) + .filter(d => fs.statSync(d).isDirectory()); + + if (groupDirs.length === 0) { + console.error(`No perf-results-* directories found in ${resultsDir}`); + return null; + } + + /** @type {Record} */ + const mergedScenarios = {}; + /** @type {Record} */ + const mergedBaselineScenarios = {}; + let runsPerScenario = 0; + let platform = 'linux'; + /** @type {string | undefined} */ + let baselineBuildVersion; + /** @type {string | undefined} */ + let threshold; + + // Read per-metric thresholds from config.jsonc (same source as the perf script) + const perfConfig = loadConfig('perfRegression'); + /** @type {Record} */ + const metricThresholds = perfConfig.metricThresholds ?? {}; + + for (const groupDir of groupDirs) { + // Find results.json (may be in a timestamped subdir under .chat-simulation-data) + const simDataDir = path.join(groupDir, '.chat-simulation-data'); + if (!fs.existsSync(simDataDir)) { continue; } + + // Search for results.json in timestamped subdirs + const subdirs = fs.readdirSync(simDataDir).filter(d => { + const full = path.join(simDataDir, d); + return fs.statSync(full).isDirectory() && /^\d{4}-/.test(d); + }); + + for (const subdir of subdirs) { + const resultsPath = path.join(simDataDir, subdir, 'results.json'); + if (fs.existsSync(resultsPath)) { + const results = JSON.parse(fs.readFileSync(resultsPath, 'utf-8')); + runsPerScenario = results.runsPerScenario || runsPerScenario; + platform = results.platform || platform; + for (const [scenario, data] of Object.entries(results.scenarios || {})) { + mergedScenarios[scenario] = data; + } + } + + // Find baseline-*.json in the same dir + const baselineFiles = fs.readdirSync(path.join(simDataDir, subdir)) + .filter(f => f.startsWith('baseline-') && f.endsWith('.json')); + for (const bf of baselineFiles) { + const baseline = JSON.parse(fs.readFileSync(path.join(simDataDir, subdir, bf), 'utf-8')); + baselineBuildVersion = baseline.baselineBuildVersion || baselineBuildVersion; + for (const [scenario, data] of Object.entries(baseline.scenarios || {})) { + mergedBaselineScenarios[scenario] = data; + } + } + } + + // Also check for baseline cached at top-level .chat-simulation-data + const topBaselines = fs.readdirSync(simDataDir) + .filter(f => f.startsWith('baseline-') && f.endsWith('.json')); + for (const bf of topBaselines) { + const baseline = JSON.parse(fs.readFileSync(path.join(simDataDir, bf), 'utf-8')); + baselineBuildVersion = baseline.baselineBuildVersion || baselineBuildVersion; + for (const [scenario, data] of Object.entries(baseline.scenarios || {})) { + mergedBaselineScenarios[scenario] = data; + } + } + + // Read threshold/metricThresholds from the group's ci-summary or config + const ciSummaryPath = path.join(simDataDir, 'ci-summary.md'); + if (fs.existsSync(ciSummaryPath)) { + const content = fs.readFileSync(ciSummaryPath, 'utf-8'); + const thresholdMatch = content.match(/Regression threshold\*\* \| (\d+)%/); + if (thresholdMatch) { + threshold = thresholdMatch[1]; + } + } + } + + const mergedReport = { + timestamp: new Date().toISOString(), + platform, + runsPerScenario, + scenarios: mergedScenarios, + }; + + const mergedBaseline = Object.keys(mergedBaselineScenarios).length > 0 + ? { baselineBuildVersion, scenarios: mergedBaselineScenarios } + : null; + + return { report: mergedReport, baseline: mergedBaseline, baselineBuildVersion, threshold: threshold ? parseInt(threshold, 10) / 100 : undefined, metricThresholds }; +} + +// -- Summary generation (unified, single-header format) ---------------------- + +const GITHUB_REPO = 'https://github.com/microsoft/vscode'; + +/** @param {string} label */ +function formatBuildLink(label) { + if (/^[0-9a-f]{7,40}$/.test(label)) { + return `[\`${label.substring(0, 7)}\`](${GITHUB_REPO}/commit/${label})`; + } + if (/^\d+\.\d+\.\d+/.test(label)) { + return `[\`${label}\`](${GITHUB_REPO}/releases/tag/${label})`; + } + return `\`${label}\``; +} + +/** + * @param {string} base + * @param {string} test + */ +function formatCompareLink(base, test) { + const isRef = (/** @type {string} */ v) => /^[0-9a-f]{7,40}$/.test(v) || /^\d+\.\d+\.\d+/.test(v); + if (!isRef(base) || !isRef(test)) { return ''; } + return `[compare](${GITHUB_REPO}/compare/${base}...${test})`; +} + +/** + * @param {{ type: string, value: number }} threshold + * @param {number} change + * @param {number} absoluteDelta + */ +function exceedsThreshold(threshold, change, absoluteDelta) { + if (threshold.type === 'absolute') { return absoluteDelta > threshold.value; } + return change > threshold.value; +} + +/** + * @param {{ threshold: number, metricThresholds?: Record }} opts + * @param {string} metric + */ +function getMetricThreshold(opts, metric) { + const raw = opts.metricThresholds?.[metric]; + if (raw !== undefined) { + const num = typeof raw === 'number' ? raw : parseFloat(/** @type {string} */(raw)); + return typeof raw === 'number' ? { type: 'fraction', value: num } : { type: 'absolute', value: num }; + } + return { type: 'fraction', value: opts.threshold }; +} + +/** @param {number} v */ +function round2(v) { return Math.round(v * 100) / 100; } + +/** + * Generate a unified Markdown summary for all scenarios. + * + * @param {Record} jsonReport + * @param {Record | null} baseline + * @param {{ threshold: number, metricThresholds?: Record, runs: number, baselineBuild?: string, build?: string }} opts + */ +function generateUnifiedSummary(jsonReport, baseline, opts) { + const baseLabel = opts.baselineBuild || 'baseline'; + const testLabel = opts.build || 'dev (local)'; + const baseLink = formatBuildLink(baseLabel); + const testLink = formatBuildLink(testLabel); + const compareLink = formatCompareLink(baseLabel, testLabel); + + const allMetrics = [ + ['timeToFirstToken', 'timing', 'ms'], + ['timeToComplete', 'timing', 'ms'], + ['layoutCount', 'rendering', ''], + ['recalcStyleCount', 'rendering', ''], + ['forcedReflowCount', 'rendering', ''], + ['longTaskCount', 'rendering', ''], + ['longAnimationFrameCount', 'rendering', ''], + ['longAnimationFrameTotalMs', 'rendering', 'ms'], + ['frameCount', 'rendering', ''], + ['compositeLayers', 'rendering', ''], + ['paintCount', 'rendering', ''], + ['heapDelta', 'memory', 'MB'], + ['heapDeltaPostGC', 'memory', 'MB'], + ['gcDurationMs', 'memory', 'ms'], + ['extHostHeapDelta', 'extHost', 'MB'], + ['extHostHeapDeltaPostGC', 'extHost', 'MB'], + ]; + const regressionMetricNames = new Set([ + 'timeToFirstToken', 'timeToComplete', 'layoutCount', 'recalcStyleCount', + 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount', + ]); + + const lines = []; + const scenarios = Object.keys(jsonReport.scenarios); + + // -- Collect verdicts ------------------------------------------------ + /** @type {Map} */ + const scenarioVerdicts = new Map(); + let totalRegressions = 0; + let totalImprovements = 0; + + for (const scenario of scenarios) { + const current = jsonReport.scenarios[scenario]; + const base = baseline?.scenarios?.[scenario]; + /** @type {{ metric: string, verdict: string, change: number, pValue: string, basStr: string, curStr: string }[]} */ + const verdicts = []; + + if (base) { + for (const [metric, group, unit] of allMetrics) { + const cur = current[group]?.[metric]; + const bas = base[group]?.[metric]; + if (!cur || !bas || bas.median === null || bas.median === undefined) { continue; } + + const change = bas.median !== 0 ? (cur.median - bas.median) / bas.median : 0; + const isRegressionMetric = regressionMetricNames.has(metric); + + const curRaw = (current.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + const basRaw = (base.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + const ttest = welchTTest(basRaw, curRaw); + const pStr = ttest ? `${ttest.pValue}` : 'n/a'; + + const metricThreshold = getMetricThreshold(opts, metric); + const absoluteDelta = cur.median - bas.median; + let verdict = ''; + if (isRegressionMetric) { + if (exceedsThreshold(metricThreshold, change, absoluteDelta)) { + if (!ttest || ttest.significant) { + verdict = 'REGRESSION'; + totalRegressions++; + } else { + verdict = 'noise'; + } + } else if (exceedsThreshold(metricThreshold, -change, -absoluteDelta) && ttest?.significant) { + verdict = 'improved'; + totalImprovements++; + } else { + verdict = 'ok'; + } + } else { + verdict = 'info'; + } + + const basStr = `${bas.median}${unit} \xb1${bas.stddev}${unit}`; + const curStr = `${cur.median}${unit} \xb1${cur.stddev}${unit}`; + verdicts.push({ metric, verdict, change, pValue: pStr, basStr, curStr }); + } + } + scenarioVerdicts.set(scenario, verdicts); + } + + // -- Header ---------------------------------------------------------- + const hasRegressions = totalRegressions > 0; + const verdictIcon = hasRegressions ? '\u274C' : '\u2705'; + let verdictText; + if (hasRegressions && totalImprovements > 0) { + verdictText = `${totalRegressions} regression(s), ${totalImprovements} improvement(s)`; + } else if (hasRegressions) { + verdictText = `${totalRegressions} regression(s) detected`; + } else if (totalImprovements > 0) { + verdictText = `No regressions \u2014 ${totalImprovements} improvement(s)`; + } else { + verdictText = 'No significant changes'; + } + + lines.push(`# ${verdictIcon} Chat Performance: ${verdictText}`); + lines.push(''); + lines.push(`| | |`); + lines.push(`|---|---|`); + lines.push(`| **Baseline** | ${baseLink} |`); + lines.push(`| **Test** | ${testLink} |`); + if (compareLink) { + lines.push(`| **Diff** | ${compareLink} |`); + } + lines.push(`| **Runs per scenario** | ${opts.runs} |`); + const overrides = Object.entries(opts.metricThresholds || {}).filter(([, v]) => { + const parsed = typeof v === 'number' ? { type: 'fraction', value: v } : { type: 'absolute', value: parseFloat(/** @type {string} */(v)) }; + return parsed.type !== 'fraction' || parsed.value !== opts.threshold; + }); + if (overrides.length > 0) { + const overrideStr = overrides.map(([k, v]) => { + if (typeof v === 'number') { + return `${k}: ${(v * 100).toFixed(0)}%`; + } + return `${k}: ${v}`; + }).join(', '); + lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% (${overrideStr}) |`); + } else { + lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% |`); + } + lines.push(`| **Scenarios** | ${scenarios.length} |`); + lines.push(`| **Platform** | ${jsonReport.platform || 'linux'} / x64 |`); + lines.push(''); + + // -- Overview table -------------------------------------------------- + lines.push('## Overview'); + lines.push(''); + lines.push('| Scenario | TTFT | Complete | Layouts | Styles | LoAF | Verdict |'); + lines.push('|----------|-----:|---------:|--------:|-------:|-----:|:-------:|'); + + for (const scenario of scenarios) { + const verdicts = scenarioVerdicts.get(scenario) || []; + const get = (/** @type {string} */ m) => verdicts.find(v => v.metric === m); + + const ttft = get('timeToFirstToken'); + const complete = get('timeToComplete'); + const layouts = get('layoutCount'); + const styles = get('recalcStyleCount'); + const loaf = get('longAnimationFrameCount'); + + const fmtCell = (/** @type {{ change: number } | undefined} */ v) => { + if (!v) { return '\u2014'; } + return `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(0)}%`; + }; + + const keyVerdicts = [ttft, complete, layouts, styles, loaf].filter(Boolean); + const hasRegression = keyVerdicts.some(v => v?.verdict === 'REGRESSION'); + const hasImproved = keyVerdicts.some(v => v?.verdict === 'improved'); + const rowVerdict = hasRegression ? '\u274C' : hasImproved ? '\u2B06\uFE0F' : '\u2705'; + + lines.push(`| ${scenario} | ${fmtCell(ttft)} | ${fmtCell(complete)} | ${fmtCell(layouts)} | ${fmtCell(styles)} | ${fmtCell(loaf)} | ${rowVerdict} |`); + } + lines.push(''); + + // -- Regressions & improvements (compact table) ---------------------- + const notableRows = []; + for (const scenario of scenarios) { + const verdicts = scenarioVerdicts.get(scenario) || []; + for (const v of verdicts) { + if (v.verdict === 'REGRESSION' || v.verdict === 'improved') { + notableRows.push({ scenario, ...v }); + } + } + } + + if (notableRows.length > 0) { + lines.push('## Regressions & Improvements'); + lines.push(''); + + lines.push('| Scenario | Metric | Baseline | Test | Change | p-value | |'); + lines.push('|----------|--------|----------|------|-------:|--------:|:-:|'); + for (const r of notableRows) { + const pct = `${r.change > 0 ? '+' : ''}${(r.change * 100).toFixed(1)}%`; + const icon = r.verdict === 'REGRESSION' ? '\u274C' : '\u2B06\uFE0F'; + lines.push(`| ${r.scenario} | ${r.metric} | ${r.basStr} | ${r.curStr} | ${pct} | ${r.pValue} | ${icon} |`); + } + lines.push(''); + } + + // -- Full details (collapsible) -------------------------------------- + lines.push('
Full metric details per scenario'); + lines.push(''); + + for (const scenario of scenarios) { + const verdicts = scenarioVerdicts.get(scenario) || []; + const base = baseline?.scenarios?.[scenario]; + + lines.push(`### ${scenario}`); + lines.push(''); + + if (!base) { + const current = jsonReport.scenarios[scenario]; + lines.push('> No baseline data for this scenario.'); + lines.push(''); + lines.push('| Metric | Value | StdDev | CV | n |'); + lines.push('|--------|------:|-------:|---:|--:|'); + for (const [metric, group, unit] of allMetrics) { + const cur = current[group]?.[metric]; + if (!cur) { continue; } + lines.push(`| ${metric} | ${cur.median}${unit} | \xb1${cur.stddev}${unit} | ${(cur.cv * 100).toFixed(0)}% | ${cur.n} |`); + } + lines.push(''); + continue; + } + + lines.push('| Metric | Baseline | Test | Change | p-value | Verdict |'); + lines.push('|--------|----------|------|--------|---------|---------|'); + for (const v of verdicts) { + const pct = `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(1)}%`; + let verdictDisplay = v.verdict; + if (v.verdict === 'REGRESSION') { verdictDisplay = '\u274C REGRESSION'; } + else if (v.verdict === 'improved') { verdictDisplay = '\u2B06\uFE0F improved'; } + else if (v.verdict === 'ok') { verdictDisplay = '\u2705 ok'; } + else if (v.verdict === 'noise') { verdictDisplay = '\uD83C\uDF2B\uFE0F noise'; } + else if (v.verdict === 'info') { verdictDisplay = '\u2139\uFE0F'; } + lines.push(`| ${v.metric} | ${v.basStr} | ${v.curStr} | ${pct} | ${v.pValue} | ${verdictDisplay} |`); + } + lines.push(''); + } + lines.push('
'); + lines.push(''); + + // -- Raw run data (collapsible) -------------------------------------- + lines.push('
Raw run data'); + lines.push(''); + for (const scenario of scenarios) { + const current = jsonReport.scenarios[scenario]; + lines.push(`### ${scenario}`); + lines.push(''); + lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) |'); + lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|'); + const runs = current.rawRuns || []; + for (let i = 0; i < runs.length; i++) { + const r = runs[i]; + lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${round2(r.longAnimationFrameTotalMs ?? 0) || '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} |`); + } + lines.push(''); + } + if (baseline) { + for (const scenario of scenarios) { + const base = baseline.scenarios?.[scenario]; + if (!base) { continue; } + lines.push(`### ${scenario} (baseline)`); + lines.push(''); + lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) |'); + lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|'); + const runs = base.rawRuns || []; + for (let i = 0; i < runs.length; i++) { + const r = runs[i]; + lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${round2(r.longAnimationFrameTotalMs ?? 0) || '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} |`); + } + lines.push(''); + } + } + lines.push('
'); + lines.push(''); + + return lines.join('\n'); +} + +// -- Main -------------------------------------------------------------------- + +function main() { + const opts = parseArgs(); + const merged = mergeResults(opts.resultsDir); + + if (!merged) { + const fallback = '\u26A0\uFE0F No perf results found to merge. Check perf-output.log artifacts.\n'; + fs.writeFileSync(opts.output, fallback); + console.log('[merge] No results found.'); + process.exit(0); + } + + const { report, baseline, baselineBuildVersion } = merged; + const scenarioCount = Object.keys(report.scenarios).length; + console.log(`[merge] Merged ${scenarioCount} scenarios from ${fs.readdirSync(opts.resultsDir).filter(d => d.startsWith('perf-results-')).length} groups`); + if (baseline) { + console.log(`[merge] Baseline: ${baselineBuildVersion || 'unknown'} (${Object.keys(baseline.scenarios).length} scenarios)`); + } + + const summary = generateUnifiedSummary(report, baseline, { + threshold: merged.threshold || opts.threshold, + metricThresholds: merged.metricThresholds, + runs: report.runsPerScenario, + baselineBuild: baselineBuildVersion, + build: process.env.TEST_COMMIT || undefined, + }); + + // Append leak summary if available + let fullSummary = summary; + if (opts.leakSummary && fs.existsSync(opts.leakSummary)) { + fullSummary += '\n' + fs.readFileSync(opts.leakSummary, 'utf-8'); + console.log('[merge] Appended leak summary'); + } + + fs.writeFileSync(opts.output, fullSummary); + console.log(`[merge] Summary written to ${opts.output}`); +} + +main(); diff --git a/scripts/chat-simulation/test-chat-mem-leaks.js b/scripts/chat-simulation/test-chat-mem-leaks.js new file mode 100644 index 0000000000000..c9816018562de --- /dev/null +++ b/scripts/chat-simulation/test-chat-mem-leaks.js @@ -0,0 +1,466 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +/** + * Chat memory leak checker — state-based approach. + * + * The idea: if you return to the same state you started from, memory should + * return to roughly the same level. Any residual growth is a potential leak. + * + * Each iteration: + * 1. Open a fresh chat (baseline state) + * 2. Measure heap + DOM nodes + * 3. Cycle through ALL registered perf scenarios (text, code blocks, + * tool calls, thinking, multi-turn, etc.) + * 4. Open a new chat (return to baseline state — clears previous session) + * 5. Measure heap + DOM nodes again + * 6. The delta is the "leaked" memory for that iteration + * + * Multiple iterations let us detect consistent leaks vs. one-time caching. + * + * Usage: + * npm run perf:chat-leak # defaults from config + * npm run perf:chat-leak -- --iterations 5 # more iterations + * npm run perf:chat-leak -- --threshold 5 # 5MB total threshold + * npm run perf:chat-leak -- --build 1.115.0 # test a specific build + */ + +const fs = require('fs'); +const path = require('path'); +const { + DATA_DIR, loadConfig, + resolveBuild, buildEnv, buildArgs, prepareRunDir, + launchVSCode, +} = require('./common/utils'); +const { + CONTENT_SCENARIOS, TOOL_CALL_SCENARIOS, MULTI_TURN_SCENARIOS, +} = require('./common/perf-scenarios'); +const { + getUserTurns, getModelTurnCount, +} = require('./common/mock-llm-server'); + +// -- Config (edit config.jsonc to change defaults) --------------------------- + +const CONFIG = loadConfig('memLeaks'); + +// -- CLI args ---------------------------------------------------------------- + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + iterations: CONFIG.iterations ?? 3, + messages: CONFIG.messages ?? 5, + verbose: false, + ci: false, + /** @type {string | undefined} */ + build: undefined, + leakThresholdMB: CONFIG.leakThresholdMB ?? 5, + /** @type {Record} */ + settingsOverrides: {}, + }; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--iterations': opts.iterations = parseInt(args[++i], 10); break; + case '--messages': case '-n': opts.messages = parseInt(args[++i], 10); break; + case '--verbose': opts.verbose = true; break; + case '--ci': opts.ci = true; break; + case '--build': case '-b': opts.build = args[++i]; break; + case '--threshold': opts.leakThresholdMB = parseFloat(args[++i]); break; + case '--setting': { + const kv = args[++i]; + const eq = kv.indexOf('='); + if (eq === -1) { console.error(`--setting requires key=value, got: ${kv}`); process.exit(1); } + const key = kv.slice(0, eq); + const raw = kv.slice(eq + 1); + const val = raw === 'true' ? true : raw === 'false' ? false : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw; + opts.settingsOverrides[key] = val; + break; + } + case '--help': case '-h': + console.log([ + 'Chat memory leak checker (state-based)', + '', + 'Options:', + ' --iterations Number of open→work→reset cycles (default: 3)', + ' --messages Messages to send per iteration (default: 5)', + ' --ci CI mode: write Markdown summary to ci-summary.md', + ' --build Path to VS Code build or version to download', + ' --threshold Max total residual heap growth in MB (default: 5)', + ' --setting Set a VS Code setting override (repeatable)', + ' --verbose Print per-step details', + ].join('\n')); + process.exit(0); + } + } + return opts; +} + +// -- Scenario list ----------------------------------------------------------- + +/** + * Build a flat list of scenario IDs to cycle through during leak testing. + * Includes all scenario types: content-only, tool-call, and multi-turn. + * + * Content scenarios exercise varied rendering (code blocks, markdown, etc.). + * Tool-call scenarios exercise the agent loop (model → tool → model → ...). + * Multi-turn scenarios exercise user follow-ups and thinking blocks. + */ +function getScenarioIds() { + return [ + ...Object.keys(CONTENT_SCENARIOS), + ...Object.keys(TOOL_CALL_SCENARIOS), + ...Object.keys(MULTI_TURN_SCENARIOS), + ]; +} + +// -- Helpers ----------------------------------------------------------------- + +const CHAT_VIEW = 'div[id="workbench.panel.chat"]'; +const CHAT_EDITOR_SEL = `${CHAT_VIEW} .interactive-input-part .monaco-editor[role="code"]`; + +/** + * Measure heap (MB) and DOM node count after forced GC. + * @param {any} cdp + * @param {import('playwright').Page} page + */ +async function measure(cdp, page) { + await cdp.send('HeapProfiler.collectGarbage'); + await new Promise(r => setTimeout(r, 500)); + await cdp.send('HeapProfiler.collectGarbage'); + await new Promise(r => setTimeout(r, 300)); + const heapInfo = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage')); + const heapMB = Math.round(heapInfo.usedSize / 1024 / 1024 * 100) / 100; + const domNodes = await page.evaluate(() => document.querySelectorAll('*').length); + return { heapMB, domNodes }; +} + +/** + * Open a new chat session via the command palette. + * @param {import('playwright').Page} page + */ +async function openNewChat(page) { + // Use keyboard shortcut to open a new chat (clears previous session) + const newChatShortcut = process.platform === 'darwin' ? 'Meta+KeyL' : 'Control+KeyL'; + await page.keyboard.press(newChatShortcut); + await new Promise(r => setTimeout(r, 1000)); + + // Verify the chat view is visible and ready + await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 }); + await page.waitForFunction( + (sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0), + CHAT_EDITOR_SEL, { timeout: 15_000 }, + ); + await new Promise(r => setTimeout(r, 500)); +} + +/** + * Send a single message and wait for the response to complete. + * For multi-turn scenarios where the model makes multiple tool-call rounds + * before producing content, `modelTurns` controls how many completions to + * wait for. + * @param {import('playwright').Page} page + * @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise }} mockServer + * @param {string} text + * @param {number} [modelTurns=1] - number of model completions to wait for + */ +async function sendMessage(page, mockServer, text, modelTurns = 1) { + await page.click(CHAT_EDITOR_SEL); + await new Promise(r => setTimeout(r, 200)); + + const inputSel = await page.evaluate((editorSel) => { + const ed = document.querySelector(editorSel); + if (!ed) { throw new Error('no editor'); } + return ed.querySelector('.native-edit-context') ? editorSel + ' .native-edit-context' : editorSel + ' textarea'; + }, CHAT_EDITOR_SEL); + + const hasDriver = await page.evaluate(() => + // @ts-ignore + !!globalThis.driver?.typeInEditor + ).catch(() => false); + + if (hasDriver) { + await page.evaluate(({ selector, t }) => { + // @ts-ignore + return globalThis.driver.typeInEditor(selector, t); + }, { selector: inputSel, t: text }); + } else { + await page.click(inputSel); + await new Promise(r => setTimeout(r, 200)); + await page.locator(inputSel).pressSequentially(text, { delay: 0 }); + } + + const compBefore = mockServer.completionCount(); + await page.keyboard.press('Enter'); + try { await mockServer.waitForCompletion(compBefore + modelTurns, 60_000); } catch { } + + const responseSelector = `${CHAT_VIEW} .interactive-item-container.interactive-response`; + await page.waitForFunction( + (sel) => { + const responses = document.querySelectorAll(sel); + if (responses.length === 0) { return false; } + return !responses[responses.length - 1].classList.contains('chat-response-loading'); + }, + responseSelector, { timeout: 30_000 }, + ); + await new Promise(r => setTimeout(r, 500)); +} + +/** + * Run a full scenario: send the initial message, then handle any user + * follow-up turns for multi-turn scenarios. + * + * - Content-only scenarios: single message, 1 model turn. + * - Tool-call scenarios (no user turns): single message, N model turns + * (the extension automatically relays tool results back to the model). + * - Multi-turn with user turns: send initial message, wait for response, + * then for each user turn send the follow-up message and wait again. + * + * @param {import('playwright').Page} page + * @param {{ completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise }} mockServer + * @param {string} scenarioId + * @param {string} label - prefix for the message (e.g. "Warmup" or "Iteration 2") + */ +async function runScenario(page, mockServer, scenarioId, label) { + const userTurns = getUserTurns(scenarioId); + const totalModelTurns = getModelTurnCount(scenarioId); + + if (userTurns.length === 0) { + // Content-only or tool-call scenario: one message, wait for all model turns + await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, totalModelTurns); + } else { + // Multi-turn with user follow-ups: send initial message and wait for + // the model turns before the first user turn, then alternate. + let modelTurnsSoFar = 0; + const firstUserAfter = userTurns[0].afterModelTurn; + const turnsBeforeFirstUser = firstUserAfter - modelTurnsSoFar; + await sendMessage(page, mockServer, `[scenario:${scenarioId}] ${label}`, turnsBeforeFirstUser); + modelTurnsSoFar = firstUserAfter; + + for (let u = 0; u < userTurns.length; u++) { + const nextModelStop = u + 1 < userTurns.length + ? userTurns[u + 1].afterModelTurn + : totalModelTurns; + const turnsUntilNext = nextModelStop - modelTurnsSoFar; + + // Send the user follow-up message + await sendMessage(page, mockServer, userTurns[u].message, turnsUntilNext); + modelTurnsSoFar = nextModelStop; + } + } +} + +// -- Leak check -------------------------------------------------------------- + +/** + * @param {string} electronPath + * @param {{ url: string, requestCount: () => number, waitForRequests: (n: number, ms: number) => Promise, completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise }} mockServer + * @param {{ iterations: number, verbose: boolean, settingsOverrides?: Record }} opts + */ +async function runLeakCheck(electronPath, mockServer, opts) { + const { iterations, verbose } = opts; + const { userDataDir, extDir, logsDir } = prepareRunDir('leak-check', mockServer, opts.settingsOverrides); + const isDevBuild = !electronPath.includes('.vscode-test'); + + const vscode = await launchVSCode( + electronPath, + buildArgs(userDataDir, extDir, logsDir, { isDevBuild }), + buildEnv(mockServer, { isDevBuild }), + { verbose }, + ); + const page = vscode.page; + + try { + await page.waitForSelector('.monaco-workbench', { timeout: 60_000 }); + + const cdp = await page.context().newCDPSession(page); + await cdp.send('HeapProfiler.enable'); + + // Open chat panel + const chatShortcut = process.platform === 'darwin' ? 'Control+Meta+KeyI' : 'Control+Alt+KeyI'; + await page.keyboard.press(chatShortcut); + await page.waitForSelector(CHAT_VIEW, { timeout: 15_000 }); + await page.waitForFunction( + (sel) => Array.from(document.querySelectorAll(sel)).some(el => el.getBoundingClientRect().width > 0), + CHAT_EDITOR_SEL, { timeout: 15_000 }, + ); + + // Wait for extension activation + const reqsBefore = mockServer.requestCount(); + try { await mockServer.waitForRequests(reqsBefore + 4, 30_000); } catch { } + await new Promise(r => setTimeout(r, 3000)); + + const scenarioIds = getScenarioIds(); + + // --- Baseline measurement (fresh chat) --- + const baseline = await measure(cdp, page); + if (verbose) { + console.log(` [leak] Baseline: heap=${baseline.heapMB}MB, domNodes=${baseline.domNodes}`); + } + + /** @type {{ beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[]} */ + const iterationResults = []; + + for (let iter = 0; iter < iterations; iter++) { + // Measure at start of iteration (should be in "clean" state) + const before = await measure(cdp, page); + + if (verbose) { + console.log(` [leak] Iteration ${iter + 1}/${iterations}: start heap=${before.heapMB}MB, domNodes=${before.domNodes}`); + } + + // Do work: cycle through all scenarios + for (let m = 0; m < scenarioIds.length; m++) { + const sid = scenarioIds[m]; + await runScenario(page, mockServer, sid, `Iteration ${iter + 1}`); + if (verbose) { + console.log(` [leak] Sent ${sid} (${m + 1}/${scenarioIds.length})`); + } + } + + // Return to clean state: open a new empty chat + await openNewChat(page); + await new Promise(r => setTimeout(r, 1000)); + + // Measure after returning to clean state + const after = await measure(cdp, page); + const deltaHeapMB = Math.round((after.heapMB - before.heapMB) * 100) / 100; + const deltaDomNodes = after.domNodes - before.domNodes; + + iterationResults.push({ + beforeHeapMB: before.heapMB, + afterHeapMB: after.heapMB, + deltaHeapMB, + beforeDomNodes: before.domNodes, + afterDomNodes: after.domNodes, + deltaDomNodes, + }); + + if (verbose) { + console.log(` [leak] Iteration ${iter + 1}/${iterations}: end heap=${after.heapMB}MB (delta=${deltaHeapMB}MB), domNodes=${after.domNodes} (delta=${deltaDomNodes})`); + } + } + + // Final measurement + const final = await measure(cdp, page); + const totalResidualMB = Math.round((final.heapMB - baseline.heapMB) * 100) / 100; + const totalResidualNodes = final.domNodes - baseline.domNodes; + + return { + baseline, + final: { heapMB: final.heapMB, domNodes: final.domNodes }, + totalResidualMB, + totalResidualNodes, + iterations: iterationResults, + }; + } finally { + await vscode.close(); + } +} + +// -- Main -------------------------------------------------------------------- + +async function main() { + const opts = parseArgs(); + const electronPath = await resolveBuild(opts.build); + + if (!fs.existsSync(electronPath)) { + console.error(`Electron not found at: ${electronPath}`); + process.exit(1); + } + + const { startServer } = require('./common/mock-llm-server'); + const { registerPerfScenarios } = require('./common/perf-scenarios'); + registerPerfScenarios(); + const mockServer = await startServer(0); + + console.log(`[chat-simulation] Leak check: ${opts.iterations} iterations × ${getScenarioIds().length} scenarios, threshold ${opts.leakThresholdMB}MB total`); + console.log(`[chat-simulation] Build: ${electronPath}`); + console.log(''); + + const result = await runLeakCheck(electronPath, mockServer, opts); + + console.log('[chat-simulation] =================== Leak Check Results ==================='); + console.log(''); + console.log(` Baseline: heap=${result.baseline.heapMB}MB, domNodes=${result.baseline.domNodes}`); + console.log(` Final: heap=${result.final.heapMB}MB, domNodes=${result.final.domNodes}`); + console.log(''); + for (let i = 0; i < result.iterations.length; i++) { + const it = result.iterations[i]; + console.log(` Iteration ${i + 1}: ${it.beforeHeapMB}MB → ${it.afterHeapMB}MB (residual: ${it.deltaHeapMB > 0 ? '+' : ''}${it.deltaHeapMB}MB, DOM: ${it.deltaDomNodes > 0 ? '+' : ''}${it.deltaDomNodes} nodes)`); + } + console.log(''); + console.log(` Total residual heap growth: ${result.totalResidualMB > 0 ? '+' : ''}${result.totalResidualMB}MB`); + console.log(` Total residual DOM growth: ${result.totalResidualNodes > 0 ? '+' : ''}${result.totalResidualNodes} nodes`); + console.log(''); + + // Write JSON + const jsonPath = path.join(DATA_DIR, 'chat-simulation-leak-results.json'); + fs.writeFileSync(jsonPath, JSON.stringify({ + timestamp: new Date().toISOString(), + leakThresholdMB: opts.leakThresholdMB, + iterationCount: opts.iterations, + scenarioCount: getScenarioIds().length, + ...result, + }, null, 2)); + console.log(`[chat-simulation] Results written to ${jsonPath}`); + + const leaked = result.totalResidualMB > opts.leakThresholdMB; + console.log(''); + if (leaked) { + console.log(`[chat-simulation] LEAK DETECTED — ${result.totalResidualMB}MB residual exceeds ${opts.leakThresholdMB}MB threshold`); + } else { + console.log(`[chat-simulation] No leak detected (${result.totalResidualMB}MB residual < ${opts.leakThresholdMB}MB threshold)`); + } + + if (opts.ci) { + const summary = generateLeakCISummary(result, opts); + const summaryPath = path.join(DATA_DIR, 'ci-summary-leak.md'); + fs.writeFileSync(summaryPath, summary); + console.log(`[chat-simulation] CI summary written to ${summaryPath}`); + } + + await mockServer.close(); + process.exit(leaked ? 1 : 0); +} + +/** + * Generate a Markdown summary for CI, matching the perf script pattern. + * @param {{ baseline: { heapMB: number, domNodes: number }, final: { heapMB: number, domNodes: number }, totalResidualMB: number, totalResidualNodes: number, iterations: { beforeHeapMB: number, afterHeapMB: number, deltaHeapMB: number, beforeDomNodes: number, afterDomNodes: number, deltaDomNodes: number }[] }} result + * @param {{ leakThresholdMB: number, iterations: number }} opts + */ +function generateLeakCISummary(result, opts) { + const leaked = result.totalResidualMB > opts.leakThresholdMB; + const verdict = leaked ? '\u274C **LEAK DETECTED**' : '\u2705 **No leak detected**'; + const lines = []; + lines.push('## Memory Leak Check'); + lines.push(''); + lines.push('| | |'); + lines.push('|---|---|'); + lines.push(`| **Verdict** | ${verdict} |`); + lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB |`); + lines.push(`| **Iterations** | ${opts.iterations} |`); + lines.push(`| **Scenarios per iteration** | ${getScenarioIds().length} |`); + lines.push(''); + lines.push('| Phase | Heap (MB) | DOM Nodes |'); + lines.push('|-------|----------:|----------:|'); + lines.push(`| Baseline | ${result.baseline.heapMB} | ${result.baseline.domNodes} |`); + for (let i = 0; i < result.iterations.length; i++) { + const it = result.iterations[i]; + const sign = it.deltaHeapMB > 0 ? '+' : ''; + const domSign = it.deltaDomNodes > 0 ? '+' : ''; + lines.push(`| Iteration ${i + 1} | ${it.afterHeapMB} (${sign}${it.deltaHeapMB}) | ${it.afterDomNodes} (${domSign}${it.deltaDomNodes}) |`); + } + lines.push(`| **Final** | **${result.final.heapMB}** | **${result.final.domNodes}** |`); + lines.push(''); + const sign = result.totalResidualMB > 0 ? '+' : ''; + const domSign = result.totalResidualNodes > 0 ? '+' : ''; + lines.push(`**Total residual growth:** ${sign}${result.totalResidualMB} MB heap, ${domSign}${result.totalResidualNodes} DOM nodes`); + lines.push(''); + return lines.join('\n'); +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/scripts/chat-simulation/test-chat-perf-regression.js b/scripts/chat-simulation/test-chat-perf-regression.js new file mode 100644 index 0000000000000..d93583512e7d7 --- /dev/null +++ b/scripts/chat-simulation/test-chat-perf-regression.js @@ -0,0 +1,1825 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +/** + * Chat performance benchmark. + * + * Uses the real copilot extension with IS_SCENARIO_AUTOMATION=1 and a local + * mock LLM server. Measures the full stack: prompt building, context + * gathering, tool resolution, rendering, GC, and layout overhead. + * + * Usage: + * npm run perf:chat # all scenarios vs 1.115.0 + * npm run perf:chat -- --runs 10 # 10 runs per scenario + * npm run perf:chat -- --scenario text-only # single scenario + * npm run perf:chat -- --no-baseline # skip baseline comparison + * npm run perf:chat -- --build 1.110.0 --baseline-build 1.115.0 + * npm run perf:chat -- --resume .chat-simulation-data/2026-04-14/results.json --runs 3 + */ + +const path = require('path'); +const fs = require('fs'); +const { + ROOT, DATA_DIR, METRIC_DEFS, loadConfig, + resolveBuild, isVersionString, buildEnv, buildArgs, prepareRunDir, + robustStats, welchTTest, summarize, markDuration, launchVSCode, + getNextExtHostInspectPort, connectToExtHostInspector, getRepoRoot, +} = require('./common/utils'); +const { getUserTurns, getScenarioIds } = require('./common/mock-llm-server'); +const { registerPerfScenarios, getScenarioDescription } = require('./common/perf-scenarios'); + +// -- Config (edit config.jsonc to change defaults) --------------------------- + +const CONFIG = loadConfig('perfRegression'); + +// -- CLI args ---------------------------------------------------------------- + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + runs: CONFIG.runsPerScenario ?? 5, + verbose: false, + ci: false, + noCache: false, + force: false, + /** @type {string[]} */ + scenarios: [], + /** @type {string | undefined} */ + build: undefined, + /** @type {string | undefined} */ + baseline: undefined, + /** @type {string | undefined} */ + baselineBuild: CONFIG.baselineBuild ?? '1.115.0', + saveBaseline: false, + threshold: CONFIG.regressionThreshold ?? 0.2, + /** @type {Record} */ + metricThresholds: CONFIG.metricThresholds ?? {}, + /** @type {string | undefined} */ + resume: undefined, + productionBuild: false, + /** @type {Record} */ + settingsOverrides: {}, + /** @type {Record} */ + testSettingsOverrides: {}, + /** @type {Record} */ + baselineSettingsOverrides: {}, + }; + for (let i = 0; i < args.length; i++) { + switch (args[i]) { + case '--runs': opts.runs = parseInt(args[++i], 10); break; + case '--verbose': opts.verbose = true; break; + case '--scenario': case '-s': opts.scenarios.push(args[++i]); break; + case '--build': case '-b': opts.build = args[++i]; break; + case '--baseline': opts.baseline = args[++i]; break; + case '--baseline-build': opts.baselineBuild = args[++i]; break; + case '--no-baseline': opts.baselineBuild = undefined; break; + case '--save-baseline': opts.saveBaseline = true; break; + case '--threshold': opts.threshold = parseFloat(args[++i]); break; + case '--resume': opts.resume = args[++i]; break; + case '--production-build': opts.productionBuild = true; break; + case '--setting': case '--test-setting': case '--baseline-setting': { + const kv = args[++i]; + const eq = kv.indexOf('='); + if (eq === -1) { console.error(`${args[i - 1]} requires key=value, got: ${kv}`); process.exit(1); } + const key = kv.slice(0, eq); + const raw = kv.slice(eq + 1); + // Parse booleans and numbers, keep rest as strings + const val = raw === 'true' ? true : raw === 'false' ? false : /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw; + const flag = args[i - 1]; + if (flag === '--test-setting') { opts.testSettingsOverrides[key] = val; } + else if (flag === '--baseline-setting') { opts.baselineSettingsOverrides[key] = val; } + else { opts.settingsOverrides[key] = val; } + break; + } + case '--no-cache': opts.noCache = true; break; + case '--force': opts.force = true; break; + case '--ci': opts.ci = true; opts.noCache = true; break; + case '--help': case '-h': + console.log([ + 'Chat performance benchmark', + '', + 'Options:', + ' --runs Number of runs per scenario (default: 5)', + ' --scenario Scenario to run (repeatable; default: all)', + ' --build Path to VS Code build, or a version to download', + ' (e.g. "1.110.0", "insiders", commit hash, or local path)', + ' --baseline Compare against a baseline JSON file', + ' --baseline-build Version or path to benchmark as baseline', + ' (e.g. "1.115.0", "insiders", commit hash, or local path)', + ' --no-baseline Skip baseline comparison entirely', + ' --save-baseline Save results as the new baseline (requires --baseline )', + ' --resume Resume a previous run, adding more iterations to increase', + ' confidence. Merges new runs with existing rawRuns data', + ' --threshold Regression threshold fraction (default: 0.2 = 20%)', + ' --production-build Build a local bundled package (via gulp vscode) for', + ' apples-to-apples comparison against a release baseline', + ' --setting Set a VS Code setting override for all builds (repeatable)', + ' --test-setting Set a VS Code setting override for test build only', + ' --baseline-setting Set a VS Code setting override for baseline build only', + ' e.g. --setting chat.experimental.incrementalRendering.enabled=true', + ' --no-cache Ignore cached baseline data, always run fresh', + ' --force Skip build mode mismatch confirmation', + ' --ci CI mode: write Markdown summary to ci-summary.md (implies --no-cache)', + ' --verbose Print per-run details', + '', + 'Scenarios: ' + getScenarioIds().join(', '), + ].join('\n')); + process.exit(0); + } + } + if (opts.scenarios.length === 0) { + opts.scenarios = getScenarioIds(); + } else { + const knownIds = new Set(getScenarioIds()); + const unknown = opts.scenarios.filter(s => !knownIds.has(s)); + if (unknown.length > 0) { + console.error(`Unknown scenario(s): ${unknown.join(', ')}\nAvailable: ${[...knownIds].join(', ')}`); + process.exit(1); + } + } + return opts; +} + +// -- Build mode detection ---------------------------------------------------- + +/** + * Classify an electron path into a build mode. + * @param {string} electronPath + * @returns {'dev' | 'production' | 'release'} + */ +function detectBuildMode(electronPath) { + if (electronPath.includes('.vscode-test')) { + return 'release'; + } + if (electronPath.includes('VSCode-')) { + return 'production'; + } + return 'dev'; +} + +/** + * Return a human-readable label for a build mode. + * @param {'dev' | 'production' | 'release'} mode + * @returns {string} + */ +function buildModeLabel(mode) { + switch (mode) { + case 'dev': return 'development (unbundled)'; + case 'production': return 'production (bundled, local)'; + case 'release': return 'release (bundled, downloaded)'; + } +} + +// -- Production build -------------------------------------------------------- + +/** + * Build a local production (bundled) VS Code package using `gulp vscode`. + * Returns the path to the Electron executable in the packaged output. + * + * The gulp task compiles TypeScript, bundles JS, and packages with Electron + * into `../VSCode--/`. This is the same process used for + * release builds, minus minification and mangling. + */ +function buildProductionBuild() { + const product = require(path.join(ROOT, 'product.json')); + const platform = process.platform; + const arch = process.arch; + const destDir = path.join(ROOT, '..', `VSCode-${platform}-${arch}`); + + console.log('[chat-simulation] Building local production package (gulp vscode)...'); + console.log('[chat-simulation] This may take a few minutes on the first run.'); + + const { execSync } = require('child_process'); + try { + execSync('npm run gulp -- vscode', { + cwd: ROOT, + stdio: 'inherit', + timeout: 10 * 60 * 1000, // 10 minute timeout + }); + } catch (e) { + // The copilot shim step may fail locally when the copilot SDK is not + // fully packaged (it is normally supplied via CI). As long as the + // Electron executable was produced we can still benchmark. + console.warn('[chat-simulation] gulp vscode exited with errors (see above). Checking if executable was still produced...'); + } + + /** @type {string} */ + let electronPath; + if (platform === 'darwin') { + electronPath = path.join(destDir, `${product.nameLong}.app`, 'Contents', 'MacOS', product.nameShort); + } else if (platform === 'linux') { + electronPath = path.join(destDir, product.applicationName); + } else { + electronPath = path.join(destDir, `${product.nameShort}.exe`); + } + + if (!fs.existsSync(electronPath)) { + console.error(`[chat-simulation] Production build failed — executable not found at: ${electronPath}`); + process.exit(1); + } + + // Merge product.overrides.json into the packaged product.json. + // The overrides file contains extensionsGallery and other config that + // the OSS product.json lacks. In dev builds these are loaded at + // runtime when VSCODE_DEV is set, but the production build doesn't + // set that flag so we bake them in. + const overridesPath = path.join(ROOT, 'product.overrides.json'); + if (fs.existsSync(overridesPath)) { + /** @type {string} */ + let appDir; + if (platform === 'darwin') { + appDir = path.join(destDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app'); + } else { + appDir = path.join(destDir, 'resources', 'app'); + } + const packagedProductPath = path.join(appDir, 'product.json'); + if (fs.existsSync(packagedProductPath)) { + const packagedProduct = JSON.parse(fs.readFileSync(packagedProductPath, 'utf-8')); + const overrides = JSON.parse(fs.readFileSync(overridesPath, 'utf-8')); + const merged = Object.assign(packagedProduct, overrides); + fs.writeFileSync(packagedProductPath, JSON.stringify(merged, null, '\t')); + console.log('[chat-simulation] Merged product.overrides.json into packaged product.json'); + } + } + + console.log(`[chat-simulation] Production build ready: ${electronPath}`); + return electronPath; +} + +/** + * @typedef {{ type: 'fraction', value: number } | { type: 'absolute', value: number }} MetricThreshold + */ + +/** + * Parse a metric threshold value from config. + * - A number is treated as a fraction (e.g. 0.2 = 20%). + * - A string like "100ms" or "5" is treated as an absolute delta. + * @param {number | string} raw + * @returns {MetricThreshold} + */ +function parseMetricThreshold(raw) { + if (typeof raw === 'number') { + return { type: 'fraction', value: raw }; + } + // Strip unit suffix (ms, MB, etc.) and parse the number + const num = parseFloat(raw); + if (isNaN(num)) { + throw new Error(`Invalid metric threshold: ${raw}`); + } + return { type: 'absolute', value: num }; +} + +/** + * Get the regression threshold for a specific metric. + * Uses per-metric override from config if available, otherwise the global threshold. + * @param {{ threshold: number, metricThresholds?: Record }} opts + * @param {string} metric + * @returns {MetricThreshold} + */ +function getMetricThreshold(opts, metric) { + const raw = opts.metricThresholds?.[metric]; + if (raw !== undefined) { + return parseMetricThreshold(raw); + } + return { type: 'fraction', value: opts.threshold }; +} + +/** + * Check whether a change exceeds the threshold. + * @param {MetricThreshold} threshold + * @param {number} change - fractional change (e.g. 0.5 = 50% increase) + * @param {number} absoluteDelta - absolute difference (cur.median - bas.median) + * @returns {boolean} + */ +function exceedsThreshold(threshold, change, absoluteDelta) { + if (threshold.type === 'absolute') { + return absoluteDelta > threshold.value; + } + return change > threshold.value; +} + +// -- Metrics ----------------------------------------------------------------- + +/** + * @typedef {{ + * timeToUIUpdated: number, + * timeToFirstToken: number, + * timeToComplete: number, + * timeToRenderComplete: number, + * instructionCollectionTime: number, + * agentInvokeTime: number, + * heapUsedBefore: number, + * heapUsedAfter: number, + * heapDelta: number, + * heapDeltaPostGC: number, + * majorGCs: number, + * minorGCs: number, + * gcDurationMs: number, + * layoutCount: number, + * layoutDurationMs: number, + * recalcStyleCount: number, + * forcedReflowCount: number, + * longTaskCount: number, + * longAnimationFrameCount: number, + * longAnimationFrameTotalMs: number, + * frameCount: number, + * compositeLayers: number, + * paintCount: number, + * hasInternalMarks: boolean, + * responseHasContent: boolean, + * internalFirstToken: number, + * profilePath: string, + * tracePath: string, + * snapshotPath: string, + * extHostHeapUsedBefore: number, + * extHostHeapUsedAfter: number, + * extHostHeapDelta: number, + * extHostHeapDeltaPostGC: number, + * extHostProfilePath: string, + * extHostSnapshotPath: string, + * }} RunMetrics + */ + +// -- Single run -------------------------------------------------------------- + +/** + * @param {string} electronPath + * @param {string} scenario + * @param {{ url: string, requestCount: () => number, waitForRequests: (n: number, ms: number) => Promise, completionCount: () => number, waitForCompletion: (n: number, ms: number) => Promise }} mockServer + * @param {boolean} verbose + * @param {string} runIndex + * @param {string} runDir - timestamped run directory for diagnostics + * @param {'baseline' | 'test'} role - whether this is a baseline or test run + * @param {Record} [settingsOverrides] - custom VS Code settings + * @returns {Promise} + */ +async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, runDir, role, settingsOverrides) { + const { userDataDir, extDir, logsDir } = prepareRunDir(runIndex, mockServer, settingsOverrides); + const isDevBuild = !electronPath.includes('.vscode-test') && !electronPath.includes('VSCode-'); + // Extract a clean build label from the path. + // Dev: .build/electron/Code - OSS.app/.../Code - OSS → "dev" + // Stable: .vscode-test/vscode-darwin-arm64-1.115.0/Visual Studio Code.app/.../Electron → "1.115.0" + // Production: ../VSCode-darwin-arm64/Code - OSS.app/.../Code - OSS → "production" + let buildLabel = 'dev'; + if (!isDevBuild) { + const vscodeTestMatch = electronPath.match(/vscode-test\/vscode-[^/]*?-(\d+\.\d+\.\d+)/); + if (vscodeTestMatch) { + buildLabel = vscodeTestMatch[1]; + } else if (electronPath.includes('VSCode-')) { + buildLabel = 'production'; + } else { + buildLabel = path.basename(electronPath); + } + } + + // For dev builds from a different repo, derive the repo root from the + // electron path so that the build loads its own out/ source code. + const appRoot = isDevBuild ? (getRepoRoot(electronPath) || ROOT) : ROOT; + if (isDevBuild && appRoot !== ROOT) { + if (verbose) { + console.log(` [debug] Using appRoot from electron path: ${appRoot}`); + } + } + + // Create a per-run diagnostics directory: /-/-/ + const runDiagDir = path.join(runDir, `${role}-${buildLabel}`, runIndex.replace(/^baseline-/, '')); + fs.mkdirSync(runDiagDir, { recursive: true }); + + const tracePath = path.join(runDiagDir, 'trace.json'); + const extHostInspectPort = getNextExtHostInspectPort(); + const vscode = await launchVSCode( + electronPath, + buildArgs(userDataDir, extDir, logsDir, { isDevBuild, extHostInspectPort, traceFile: tracePath, appRoot }), + buildEnv(mockServer, { isDevBuild }), + { verbose }, + ); + activeVSCode = vscode; + const window = vscode.page; + + // Declared outside try so the finally block can clean up + /** @type {{ send: (method: string, params?: any) => Promise, on: (event: string, listener: (params: any) => void) => void, close: () => void } | null} */ + let extHostInspector = null; + /** @type {{ usedSize: number, totalSize: number } | null} */ + let extHostHeapBefore = null; + /** @type {Omit | null} */ + let partialMetrics = null; + // Timing vars hoisted for access in post-close trace parsing + let submitTime = 0; + let firstResponseTime = 0; + let responseCompleteTime = 0; + let renderCompleteTime = 0; + + try { + await window.waitForSelector('.monaco-workbench', { timeout: 60_000 }); + + const cdp = await window.context().newCDPSession(window); + await cdp.send('Performance.enable'); + const heapBefore = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage')); + + const metricsBefore = await cdp.send('Performance.getMetrics'); + + // Open chat + const chatShortcut = process.platform === 'darwin' ? 'Control+Meta+KeyI' : 'Control+Alt+KeyI'; + await window.keyboard.press(chatShortcut); + + const CHAT_VIEW = 'div[id="workbench.panel.chat"]'; + const chatEditorSel = `${CHAT_VIEW} .interactive-input-part .monaco-editor[role="code"]`; + + await window.waitForSelector(CHAT_VIEW, { timeout: 15_000 }); + await window.waitForFunction( + (selector) => Array.from(document.querySelectorAll(selector)).some(el => { + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }), + chatEditorSel, { timeout: 15_000 }, + ); + + // Dismiss dialogs + const dismissDialog = async () => { + for (const sel of ['.chat-setup-dialog', '.dialog-shadow', '.monaco-dialog-box']) { + const el = await window.$(sel); + if (el) { await window.keyboard.press('Escape'); await new Promise(r => setTimeout(r, 500)); break; } + } + }; + await dismissDialog(); + + // Wait for extension activation + const reqsBefore = mockServer.requestCount(); + try { await mockServer.waitForRequests(reqsBefore + 4, 30_000); } catch { } + if (verbose) { + console.log(` [debug] Extension active (${mockServer.requestCount() - reqsBefore} new requests)`); + } + + // Connect to extension host inspector for profiling/heap data + try { + extHostInspector = await connectToExtHostInspector(extHostInspectPort, { verbose, timeoutMs: 15_000 }); + await extHostInspector.send('HeapProfiler.enable'); + await extHostInspector.send('Profiler.enable'); + await extHostInspector.send('Profiler.start'); + extHostHeapBefore = await extHostInspector.send('Runtime.getHeapUsage'); + if (verbose && extHostHeapBefore) { + console.log(` [ext-host] Heap before: ${Math.round(extHostHeapBefore.usedSize / 1024 / 1024)}MB`); + } + } catch (err) { + if (verbose) { + console.log(` [ext-host] Could not connect to inspector: ${err}`); + } + } + + // Wait for model resolution + await new Promise(r => setTimeout(r, 3000)); + await dismissDialog(); + + // Focus input + await window.click(chatEditorSel); + const focusStart = Date.now(); + while (Date.now() - focusStart < 5_000) { + const focused = await window.evaluate((sel) => { + const el = document.querySelector(sel); + return el && (el.classList.contains('focused') || el.contains(document.activeElement)); + }, chatEditorSel).catch(() => false); + if (focused) { break; } + await new Promise(r => setTimeout(r, 50)); + } + + // Type message — use the smoke-test driver's typeInEditor when available + // (dev builds), fall back to pressSequentially for stable/insiders builds. + const chatMessage = `[scenario:${scenario}] Explain how this code works`; + const actualInputSelector = await window.evaluate((editorSel) => { + const editor = document.querySelector(editorSel); + if (!editor) { throw new Error('Chat editor not found'); } + return editor.querySelector('.native-edit-context') ? editorSel + ' .native-edit-context' : editorSel + ' textarea'; + }, chatEditorSel); + + const hasDriver = await window.evaluate(() => + // @ts-ignore + !!globalThis.driver?.typeInEditor + ).catch(() => false); + + if (hasDriver) { + await window.evaluate(({ selector, text }) => { + // @ts-ignore + return globalThis.driver.typeInEditor(selector, text); + }, { selector: actualInputSelector, text: chatMessage }); + } else { + // Fallback: click the input element and use pressSequentially + await window.click(actualInputSelector); + await new Promise(r => setTimeout(r, 200)); + await window.locator(actualInputSelector).pressSequentially(chatMessage, { delay: 0 }); + } + + // Start CPU profiler to capture call stacks during the interaction + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.start'); + + // Submit + const completionsBefore = mockServer.completionCount(); + submitTime = Date.now(); + await window.keyboard.press('Enter'); + + // Wait for mock server to serve the response + try { await mockServer.waitForCompletion(completionsBefore + 1, 60_000); } catch { } + firstResponseTime = Date.now(); + + // Wait for DOM response to settle + await dismissDialog(); + const responseSelector = `${CHAT_VIEW} .interactive-item-container.interactive-response`; + await window.waitForFunction( + (sel) => { + const responses = document.querySelectorAll(sel); + if (responses.length === 0) { return false; } + return !responses[responses.length - 1].classList.contains('chat-response-loading'); + }, + responseSelector, { timeout: 30_000 }, + ); + responseCompleteTime = Date.now(); + + // -- User turn injection loop ----------------------------------------- + // For multi-turn scenarios with user follow-ups, type each follow-up + // message and wait for the model's response to settle. + const userTurns = getUserTurns(scenario); + for (let ut = 0; ut < userTurns.length; ut++) { + const userTurn = userTurns[ut]; + if (verbose) { + console.log(` [debug] User follow-up ${ut + 1}/${userTurns.length}: "${userTurn.message}"`); + } + + // Brief pause to let the UI settle between turns + await new Promise(r => setTimeout(r, 500)); + + // Focus the chat input + await window.click(chatEditorSel); + const utFocusStart = Date.now(); + while (Date.now() - utFocusStart < 3_000) { + const focused = await window.evaluate((sel) => { + const el = document.querySelector(sel); + return el && (el.classList.contains('focused') || el.contains(document.activeElement)); + }, chatEditorSel).catch(() => false); + if (focused) { break; } + await new Promise(r => setTimeout(r, 50)); + } + + // Type the follow-up message + if (hasDriver) { + await window.evaluate(({ selector, text }) => { + // @ts-ignore + return globalThis.driver.typeInEditor(selector, text); + }, { selector: actualInputSelector, text: userTurn.message }); + } else { + await window.click(actualInputSelector); + await new Promise(r => setTimeout(r, 200)); + await window.locator(actualInputSelector).pressSequentially(userTurn.message, { delay: 0 }); + } + + // Submit follow-up + const utCompBefore = mockServer.completionCount(); + await window.keyboard.press('Enter'); + + // Wait for mock server to serve the response for this turn + try { await mockServer.waitForCompletion(utCompBefore + 1, 60_000); } catch { } + + // Wait for the new response to finish rendering. + // The chat list is virtualized — old response elements are + // recycled out of the DOM as new ones appear, so we cannot + // rely on counting DOM elements. Instead, scroll to the + // bottom and wait for no response to be in loading state. + await dismissDialog(); + await window.evaluate((chatViewSel) => { + const input = document.querySelector(chatViewSel + ' .interactive-input-part'); + if (input) { input.scrollIntoView({ block: 'end' }); } + }, CHAT_VIEW); + await new Promise(r => setTimeout(r, 200)); + + await window.waitForFunction( + (sel) => { + const responses = document.querySelectorAll(sel); + if (responses.length === 0) { return false; } + return !responses[responses.length - 1].classList.contains('chat-response-loading'); + }, + responseSelector, + { timeout: 30_000 }, + ); + responseCompleteTime = Date.now(); + + if (verbose) { + const utResponseInfo = await window.evaluate((sel) => { + const responses = document.querySelectorAll(sel); + const last = responses[responses.length - 1]; + return last ? (last.textContent || '').substring(0, 150) : '(empty)'; + }, responseSelector); + console.log(` [debug] Follow-up response (first 150 chars): ${utResponseInfo}`); + } + } + + // Stop CPU profiler and save the profile + const { profile } = /** @type {any} */ (await cdp.send('Profiler.stop')); + const profilePath = path.join(runDiagDir, 'profile.cpuprofile'); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + if (verbose) { + console.log(` [debug] CPU profile saved to ${profilePath}`); + } + + const responseInfo = await window.evaluate((sel) => { + const responses = document.querySelectorAll(sel); + const last = responses[responses.length - 1]; + if (!last) { return { hasContent: false, text: '' }; } + const text = last.textContent || ''; + return { hasContent: text.trim().length > 0, text: text.substring(0, 200) }; + }, responseSelector); + + if (verbose) { + console.log(` [debug] Response content (first 200 chars): ${responseInfo.text}`); + console.log(` [debug] Client-side timing: firstResponse=${firstResponseTime - submitTime}ms, complete=${responseCompleteTime - submitTime}ms`); + } + + // Wait for the typewriter animation to finish rendering. + // The chat UI animates streamed content word-by-word after the + // response stream completes. We need to wait until all content + // is rendered before capturing layout/style metrics, otherwise + // we miss the rendering phase where batching optimizations matter. + await window.waitForFunction( + (sel) => { + const responses = document.querySelectorAll(sel); + const last = responses[responses.length - 1]; + if (!last) { return true; } + // The typewriter animation is done when there are no + // elements with the 'typewriter' or 'animating' class, + // and no pending cursor animations. + const hasAnimating = last.querySelector('.chat-animated-word, .chat-typewriter-cursor'); + return !hasAnimating; + }, + responseSelector, + { timeout: 30_000 }, + ).catch(() => { + // Fallback: if the selector-based check doesn't work (e.g. + // the CSS classes differ across versions), wait for content + // to stabilize by polling textContent. + }); + + // Additional stabilization: poll until textContent stops changing. + // This catches any remaining animation regardless of CSS class names. + { + let prev = ''; + let stableCount = 0; + const stabilizeStart = Date.now(); + while (stableCount < 3 && Date.now() - stabilizeStart < 10_000) { + const current = await window.evaluate((sel) => { + const responses = document.querySelectorAll(sel); + const last = responses[responses.length - 1]; + return last ? (last.textContent || '') : ''; + }, responseSelector).catch(() => ''); + if (current === prev) { + stableCount++; + } else { + stableCount = 0; + prev = current; + } + await new Promise(r => setTimeout(r, 100)); + } + } + renderCompleteTime = Date.now(); + if (verbose) { + console.log(` [debug] Render stabilized: ${renderCompleteTime - responseCompleteTime}ms after stream complete`); + } + + const heapAfter = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage')); + const metricsAfter = await cdp.send('Performance.getMetrics'); + + // Take heap snapshot + const snapshotPath = path.join(runDiagDir, 'heap.heapsnapshot'); + await cdp.send('HeapProfiler.enable'); + const snapshotChunks = /** @type {string[]} */ ([]); + cdp.on('HeapProfiler.addHeapSnapshotChunk', (/** @type {any} */ params) => { + snapshotChunks.push(params.chunk); + }); + await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + fs.writeFileSync(snapshotPath, snapshotChunks.join('')); + + // -- Extension host metrics ------------------------------------------ + let extHostHeapUsedBefore = -1; + let extHostHeapUsedAfter = -1; + let extHostHeapDelta = -1; + let extHostHeapDeltaPostGC = -1; + let extHostProfilePath = ''; + let extHostSnapshotPath = ''; + if (extHostInspector && extHostHeapBefore) { + try { + extHostHeapUsedBefore = Math.round(extHostHeapBefore.usedSize / 1024 / 1024); + + // Stop CPU profiler and save + const extProfile = await extHostInspector.send('Profiler.stop'); + extHostProfilePath = path.join(runDiagDir, 'exthost-profile.cpuprofile'); + fs.writeFileSync(extHostProfilePath, JSON.stringify(extProfile.profile)); + if (verbose) { + console.log(` [ext-host] CPU profile saved to ${extHostProfilePath}`); + } + + // Heap usage after interaction + const extHostHeapAfter = await extHostInspector.send('Runtime.getHeapUsage'); + extHostHeapUsedAfter = Math.round(extHostHeapAfter.usedSize / 1024 / 1024); + extHostHeapDelta = extHostHeapUsedAfter - extHostHeapUsedBefore; + + // Force GC and measure retained heap + try { + await extHostInspector.send('Runtime.evaluate', { expression: 'gc()', awaitPromise: false, includeCommandLineAPI: true }); + await new Promise(r => setTimeout(r, 200)); + const extHostHeapPostGC = await extHostInspector.send('Runtime.getHeapUsage'); + extHostHeapDeltaPostGC = Math.round(extHostHeapPostGC.usedSize / 1024 / 1024) - extHostHeapUsedBefore; + } catch { + extHostHeapDeltaPostGC = -1; + } + + // Take ext host heap snapshot + extHostSnapshotPath = path.join(runDiagDir, 'exthost-heap.heapsnapshot'); + const extSnapshotChunks = /** @type {string[]} */ ([]); + extHostInspector.on('HeapProfiler.addHeapSnapshotChunk', (/** @type {any} */ params) => { + extSnapshotChunks.push(params.chunk); + }); + await extHostInspector.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false }); + fs.writeFileSync(extHostSnapshotPath, extSnapshotChunks.join('')); + + if (verbose) { + console.log(` [ext-host] Heap: before=${extHostHeapUsedBefore}MB, after=${extHostHeapUsedAfter}MB, delta=${extHostHeapDelta}MB, deltaPostGC=${extHostHeapDeltaPostGC}MB`); + console.log(` [ext-host] Snapshot saved to ${extHostSnapshotPath}`); + } + } catch (err) { + if (verbose) { + console.log(` [ext-host] Error collecting metrics: ${err}`); + } + } finally { + extHostInspector.close(); + } + } + + // Store partial metrics here so we can combine with trace data after close. + + /** @param {any} r @param {string} name */ + function getMetric(r, name) { + const e = r.metrics?.find((/** @type {any} */ m) => m.name === name); + return e ? e.value : 0; + } + + partialMetrics = { + heapUsedBefore: Math.round(heapBefore.usedSize / 1024 / 1024), + heapUsedAfter: Math.round(heapAfter.usedSize / 1024 / 1024), + heapDelta: Math.round((heapAfter.usedSize - heapBefore.usedSize) / 1024 / 1024), + heapDeltaPostGC: await (async () => { + // Force a full GC then measure heap to get deterministic retained-memory delta. + // --js-flags=--expose-gc is not required: CDP's Runtime.evaluate can call gc() + // when includeCommandLineAPI is true. + try { + await cdp.send('Runtime.evaluate', { expression: 'gc()', awaitPromise: false, includeCommandLineAPI: true }); + await new Promise(r => setTimeout(r, 200)); + const heapPostGC = /** @type {any} */ (await cdp.send('Runtime.getHeapUsage')); + return Math.round((heapPostGC.usedSize - heapBefore.usedSize) / 1024 / 1024); + } catch { + return -1; // gc() not available in this build + } + })(), + layoutCount: getMetric(metricsAfter, 'LayoutCount') - getMetric(metricsBefore, 'LayoutCount'), + recalcStyleCount: getMetric(metricsAfter, 'RecalcStyleCount') - getMetric(metricsBefore, 'RecalcStyleCount'), + forcedReflowCount: getMetric(metricsAfter, 'ForcedStyleRecalcs') - getMetric(metricsBefore, 'ForcedStyleRecalcs'), + frameCount: getMetric(metricsAfter, 'FrameCount') - getMetric(metricsBefore, 'FrameCount'), + compositeLayers: getMetric(metricsAfter, 'CompositeLayers') - getMetric(metricsBefore, 'CompositeLayers'), + paintCount: getMetric(metricsAfter, 'PaintCount') - getMetric(metricsBefore, 'PaintCount'), + responseHasContent: responseInfo.hasContent, + profilePath, + tracePath, + snapshotPath, + extHostHeapUsedBefore, + extHostHeapUsedAfter, + extHostHeapDelta, + extHostHeapDeltaPostGC, + extHostProfilePath, + extHostSnapshotPath, + }; + } finally { + if (extHostInspector) { + try { extHostInspector.close(); } catch { } + } + activeVSCode = null; + await vscode.close(); + } + + // Read the trace file written by VS Code on exit via --trace-startup-file + /** @type {Array} */ + let traceEvents = []; + try { + const traceData = JSON.parse(fs.readFileSync(tracePath, 'utf-8')); + traceEvents = traceData.traceEvents || []; + } catch { + // Trace file may not exist if VS Code crashed before shutdown + } + + // Extract code/chat/* perf marks from blink.user_timing trace events. + // These appear as instant ('R' or 'I') events with timestamps in microseconds. + const chatMarks = traceEvents + .filter(e => e.cat === 'blink.user_timing' && e.name && e.name.startsWith('code/chat/')) + .map(e => ({ name: e.name, startTime: e.ts / 1000 })); + + if (verbose && chatMarks.length > 0) { + console.log(` [trace] chatMarks (${chatMarks.length}): ${chatMarks.map((/** @type {any} */ m) => m.name.split('/').slice(-1)[0]).join(', ')}`); + } + + // Parse timing — prefer internal code/chat/* marks (precise, in-process) + // with client-side Date.now() as fallback for older builds without marks. + const timeToUIUpdated = markDuration(chatMarks, 'request/start', 'request/uiUpdated'); + const internalFirstToken = markDuration(chatMarks, 'request/start', 'request/firstToken'); + const timeToFirstToken = internalFirstToken >= 0 ? internalFirstToken : (firstResponseTime - submitTime); + const timeToComplete = responseCompleteTime - submitTime; + const timeToRenderComplete = renderCompleteTime - submitTime; + const instructionCollectionTime = markDuration(chatMarks, 'request/willCollectInstructions', 'request/didCollectInstructions'); + const agentInvokeTime = markDuration(chatMarks, 'agent/willInvoke', 'agent/didInvoke'); + + // Parse GC events from trace. + // Use the trace-event category and phase fields which are stable + // across V8 versions, rather than matching event name substrings. + let majorGCs = 0, minorGCs = 0, gcDurationMs = 0; + for (const event of traceEvents) { + const isGC = event.cat === 'v8.gc' + || event.cat === 'devtools.timeline,v8' + || (typeof event.cat === 'string' && event.cat.split(',').some((/** @type {string} */ c) => c.trim() === 'v8.gc')); + if (!isGC) { continue; } + // Only count complete ('X') or duration-begin ('B') events to + // avoid double-counting begin/end pairs. + if (event.ph && event.ph !== 'X' && event.ph !== 'B') { continue; } + const name = event.name || ''; + if (/Major|MarkCompact|MSC|MC|IncrementalMarking|FinalizeMC/i.test(name)) { majorGCs++; } + else if (/Minor|Scaveng/i.test(name)) { minorGCs++; } + else { minorGCs++; } // default unknown GC events to minor + if (event.dur) { gcDurationMs += event.dur / 1000; } + } + // Parse Layout duration from devtools.timeline trace events. + let layoutDurationMs = 0; + for (const event of traceEvents) { + if (event.name === 'Layout' && event.ph === 'X' && event.dur) { + layoutDurationMs += event.dur / 1000; + } + } + + let longTaskCount = 0; + for (const event of traceEvents) { + if (event.name === 'RunTask' && event.dur && event.dur > 50_000) { longTaskCount++; } + } + + // Parse Long Animation Frame (LoAF) events from devtools.timeline trace. + // AnimationFrame events use async flow pairs (ph:'s' start, ph:'f' finish) + // with matching ids. Compute duration from each s→f pair. + let longAnimationFrameCount = 0; + let longAnimationFrameTotalMs = 0; + { + /** @type {Map} */ + const frameStarts = new Map(); + for (const event of traceEvents) { + if (event.cat === 'devtools.timeline' && event.name === 'AnimationFrame') { + if (event.ph === 's') { + frameStarts.set(event.id, event.ts); + } else if (event.ph === 'f' && frameStarts.has(event.id)) { + const durationMs = (event.ts - /** @type {number} */(frameStarts.get(event.id))) / 1000; + frameStarts.delete(event.id); + if (durationMs > 50) { + longAnimationFrameCount++; + longAnimationFrameTotalMs += durationMs; + } + } + } + } + } + + return { + ...partialMetrics, + timeToUIUpdated, timeToFirstToken, timeToComplete, timeToRenderComplete, instructionCollectionTime, agentInvokeTime, + hasInternalMarks: chatMarks.length > 0, + internalFirstToken, + majorGCs, minorGCs, + gcDurationMs: Math.round(gcDurationMs * 100) / 100, + layoutDurationMs: Math.round(layoutDurationMs * 100) / 100, + longTaskCount, + longAnimationFrameCount, + longAnimationFrameTotalMs: Math.round(longAnimationFrameTotalMs * 100) / 100, + }; +} + +// -- CI summary generation --------------------------------------------------- + +const GITHUB_REPO = 'https://github.com/microsoft/vscode'; + +/** + * Format a build identifier as a Markdown link when possible. + * - Commit SHAs link to the commit page. + * - Semver versions link to the release tag page. + * - Everything else (e.g. "baseline", "dev (local)") is returned as inline code. + * @param {string} label + * @returns {string} + */ +function formatBuildLink(label) { + if (/^[0-9a-f]{7,40}$/.test(label)) { + const short = label.substring(0, 7); + return `[\`${short}\`](${GITHUB_REPO}/commit/${label})`; + } + if (/^\d+\.\d+\.\d+/.test(label)) { + return `[\`${label}\`](${GITHUB_REPO}/releases/tag/${label})`; + } + return `\`${label}\``; +} + +/** + * Build a GitHub compare link between two build identifiers, if both are + * commit-like or version-like references. Returns empty string otherwise. + * @param {string} base + * @param {string} test + * @returns {string} + */ +function formatCompareLink(base, test) { + const isRef = (/** @type {string} */ v) => /^[0-9a-f]{7,40}$/.test(v) || /^\d+\.\d+\.\d+/.test(v); + if (!isRef(base) || !isRef(test)) { + return ''; + } + return `[compare](${GITHUB_REPO}/compare/${base}...${test})`; +} + +/** + * Generate a detailed Markdown summary table for CI. + * Printed to stdout and written to ci-summary.md. + * + * @param {Record} jsonReport + * @param {Record | null} baseline + * @param {{ threshold: number, metricThresholds?: Record, runs: number, baselineBuild?: string, build?: string }} opts + */ +function generateCISummary(jsonReport, baseline, opts) { + const baseLabel = opts.baselineBuild || 'baseline'; + const testBuildMode = jsonReport.buildMode || 'dev'; + const testLabel = testBuildMode === 'dev' ? 'dev (local)' + : testBuildMode === 'production' ? 'production (local)' + : opts.build || testBuildMode; + const baseLink = formatBuildLink(baseLabel); + const testLink = formatBuildLink(testLabel); + const compareLink = formatCompareLink(baseLabel, testLabel); + const allMetrics = [ + ['timeToFirstToken', 'timing', 'ms'], + ['timeToComplete', 'timing', 'ms'], + ['layoutCount', 'rendering', ''], + ['recalcStyleCount', 'rendering', ''], + ['forcedReflowCount', 'rendering', ''], + ['longTaskCount', 'rendering', ''], + ['longAnimationFrameCount', 'rendering', ''], + ['longAnimationFrameTotalMs', 'rendering', 'ms'], + ['frameCount', 'rendering', ''], + ['compositeLayers', 'rendering', ''], + ['paintCount', 'rendering', ''], + ['heapDelta', 'memory', 'MB'], + ['heapDeltaPostGC', 'memory', 'MB'], + ['gcDurationMs', 'memory', 'ms'], + ['extHostHeapDelta', 'extHost', 'MB'], + ['extHostHeapDeltaPostGC', 'extHost', 'MB'], + ]; + const regressionMetricNames = new Set(['timeToFirstToken', 'timeToComplete', 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount']); + + const lines = []; + const scenarios = Object.keys(jsonReport.scenarios); + + // -- Collect verdicts per scenario/metric -------------------------------- + /** @type {Map} */ + const scenarioVerdicts = new Map(); + let totalRegressions = 0; + let totalImprovements = 0; + + for (const scenario of scenarios) { + const current = jsonReport.scenarios[scenario]; + const base = baseline?.scenarios?.[scenario]; + /** @type {{ metric: string, verdict: string, change: number, pValue: string, basStr: string, curStr: string }[]} */ + const verdicts = []; + + if (base) { + for (const [metric, group, unit] of allMetrics) { + const cur = current[group]?.[metric]; + const bas = base[group]?.[metric]; + if (!cur || !bas || bas.median === null || bas.median === undefined) { continue; } + + const change = bas.median !== 0 ? (cur.median - bas.median) / bas.median : 0; + const isRegressionMetric = regressionMetricNames.has(metric); + + const curRaw = (current.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + const basRaw = (base.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + const ttest = welchTTest(basRaw, curRaw); + const pStr = ttest ? `${ttest.pValue}` : 'n/a'; + + const metricThreshold = getMetricThreshold(opts, metric); + const absoluteDelta = cur.median - bas.median; + let verdict = ''; + if (isRegressionMetric) { + if (exceedsThreshold(metricThreshold, change, absoluteDelta)) { + if (!ttest || ttest.significant) { + verdict = 'REGRESSION'; + totalRegressions++; + } else { + verdict = 'noise'; + } + } else if (exceedsThreshold(metricThreshold, -change, -absoluteDelta) && ttest?.significant) { + verdict = 'improved'; + totalImprovements++; + } else { + verdict = 'ok'; + } + } else { + verdict = 'info'; + } + + const basStr = `${bas.median}${unit} \xb1${bas.stddev}${unit}`; + const curStr = `${cur.median}${unit} \xb1${cur.stddev}${unit}`; + verdicts.push({ metric, verdict, change, pValue: pStr, basStr, curStr }); + } + } + scenarioVerdicts.set(scenario, verdicts); + } + + // -- Header with verdict up front ---------------------------------------- + const hasRegressions = totalRegressions > 0; + const verdictIcon = hasRegressions ? '\u274C' : '\u2705'; + const verdictText = hasRegressions + ? `${totalRegressions} regression(s) detected` + : totalImprovements > 0 + ? `No regressions \u2014 ${totalImprovements} improvement(s)` + : 'No significant changes'; + + lines.push(`# ${verdictIcon} Chat Performance: ${verdictText}`); + lines.push(''); + lines.push(`| | |`); + lines.push(`|---|---|`); + lines.push(`| **Baseline** | ${baseLink} |`); + lines.push(`| **Test** | ${testLink} |`); + if (compareLink) { + lines.push(`| **Diff** | ${compareLink} |`); + } + lines.push(`| **Runs per scenario** | ${opts.runs} |`); + const overrides = Object.entries(opts.metricThresholds || {}).filter(([, v]) => { + const parsed = parseMetricThreshold(v); + return parsed.type !== 'fraction' || parsed.value !== opts.threshold; + }); + if (overrides.length > 0) { + const overrideStr = overrides.map(([k, v]) => { + const parsed = parseMetricThreshold(v); + return `${k}: ${parsed.type === 'absolute' ? `${parsed.value}${k.includes('Ms') || k.includes('Time') || k.includes('time') ? 'ms' : ''}` : `${(parsed.value * 100).toFixed(0)}%`}`; + }).join(', '); + lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% (${overrideStr}) |`); + } else { + lines.push(`| **Regression threshold** | ${(opts.threshold * 100).toFixed(0)}% |`); + } + lines.push(`| **Scenarios** | ${scenarios.length} |`); + lines.push(`| **Platform** | ${process.platform} / ${process.arch} |`); + if (jsonReport.buildMode) { + lines.push(`| **Build mode** | ${jsonReport.buildMode} |`); + } + lines.push(''); + if (jsonReport.mismatchedBuildMode) { + lines.push('> **⚠ Build mode mismatch:** The test and baseline builds use different build modes.'); + lines.push('> Results may not be directly comparable. For apples-to-apples comparisons,'); + lines.push('> use the same build type for both (e.g. `--production-build` with a local'); + lines.push('> baseline path, or two version strings).'); + lines.push(''); + } + + // -- At-a-glance overview table: one row per scenario -------------------- + lines.push(`## Overview`); + lines.push(''); + lines.push('| Scenario | Description | TTFT | Complete | Layouts | Styles | LoAF | Verdict |'); + lines.push('|----------|-------------|-----:|---------:|--------:|-------:|-----:|:-------:|'); + + for (const scenario of scenarios) { + const verdicts = scenarioVerdicts.get(scenario) || []; + const get = (/** @type {string} */ m) => verdicts.find(v => v.metric === m); + + const ttft = get('timeToFirstToken'); + const complete = get('timeToComplete'); + const layouts = get('layoutCount'); + const styles = get('recalcStyleCount'); + const loaf = get('longAnimationFrameCount'); + + const fmtCell = (/** @type {{ change: number, verdict: string } | undefined} */ v) => { + if (!v) { return '\u2014'; } + const pct = `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(0)}%`; + return pct; + }; + + const fmtVerdict = (/** @type {{ verdict: string, change: number }[]} */ vs) => { + const hasRegression = vs.some(v => v.verdict === 'REGRESSION'); + const hasImproved = vs.some(v => v.verdict === 'improved'); + if (hasRegression) { return '\u274C Regressed'; } + if (hasImproved) { return '\u2B06\uFE0F Improved'; } + return '\u2705 OK'; + }; + + const keyVerdicts = [ttft, complete, layouts, styles, loaf].filter(Boolean); + const rowVerdict = fmtVerdict(/** @type {any[]} */(keyVerdicts)); + + lines.push(`| ${scenario} | ${getScenarioDescription(scenario)} | ${fmtCell(ttft)} | ${fmtCell(complete)} | ${fmtCell(layouts)} | ${fmtCell(styles)} | ${fmtCell(loaf)} | ${rowVerdict} |`); + } + lines.push(''); + + // -- Regressions & improvements detail section --------------------------- + const hasNotable = [...scenarioVerdicts.values()].some(vs => vs.some(v => v.verdict === 'REGRESSION' || v.verdict === 'improved')); + if (hasNotable) { + lines.push('## Regressions & Improvements'); + lines.push(''); + lines.push('Only metrics that regressed or improved significantly are shown below.'); + lines.push(''); + + for (const scenario of scenarios) { + const verdicts = scenarioVerdicts.get(scenario) || []; + const notable = verdicts.filter(v => v.verdict === 'REGRESSION' || v.verdict === 'improved'); + if (notable.length === 0) { continue; } + + const icon = notable.some(v => v.verdict === 'REGRESSION') ? '\u274C' : '\u2B06\uFE0F'; + lines.push(`### ${icon} ${scenario}`); + lines.push(''); + lines.push('| Metric | Baseline | Test | Change | p-value | Verdict |'); + lines.push('|--------|----------|------|--------|---------|---------|'); + for (const v of notable) { + const pct = `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(1)}%`; + const verdictIcon = v.verdict === 'REGRESSION' ? '\u274C' : '\u2B06\uFE0F'; + lines.push(`| ${v.metric} | ${v.basStr} | ${v.curStr} | ${pct} | ${v.pValue} | ${verdictIcon} ${v.verdict} |`); + } + lines.push(''); + } + } + + // -- Full metric tables in collapsible section --------------------------- + lines.push('
Full metric details per scenario'); + lines.push(''); + + for (const scenario of scenarios) { + const verdicts = scenarioVerdicts.get(scenario) || []; + const base = baseline?.scenarios?.[scenario]; + + lines.push(`### ${scenario}`); + lines.push(''); + + if (!base) { + const current = jsonReport.scenarios[scenario]; + lines.push('> No baseline data for this scenario.'); + lines.push(''); + lines.push('| Metric | Value | StdDev | CV | n |'); + lines.push('|--------|------:|-------:|---:|--:|'); + for (const [metric, group, unit] of allMetrics) { + const cur = current[group]?.[metric]; + if (!cur) { continue; } + lines.push(`| ${metric} | ${cur.median}${unit} | \xb1${cur.stddev}${unit} | ${(cur.cv * 100).toFixed(0)}% | ${cur.n} |`); + } + lines.push(''); + continue; + } + + lines.push(`| Metric | Baseline | Test | Change | p-value | Verdict |`); + lines.push(`|--------|----------|------|--------|---------|---------|`); + + for (const v of verdicts) { + const pct = `${v.change > 0 ? '+' : ''}${(v.change * 100).toFixed(1)}%`; + let verdictDisplay = v.verdict; + if (v.verdict === 'REGRESSION') { verdictDisplay = '\u274C REGRESSION'; } + else if (v.verdict === 'improved') { verdictDisplay = '\u2B06\uFE0F improved'; } + else if (v.verdict === 'ok') { verdictDisplay = '\u2705 ok'; } + else if (v.verdict === 'noise') { verdictDisplay = '\uD83C\uDF2B\uFE0F noise'; } + else if (v.verdict === 'info') { verdictDisplay = '\u2139\uFE0F'; } + lines.push(`| ${v.metric} | ${v.basStr} | ${v.curStr} | ${pct} | ${v.pValue} | ${verdictDisplay} |`); + } + lines.push(''); + } + lines.push('
'); + lines.push(''); + + // -- Raw run data in collapsible section --------------------------------- + lines.push('
Raw run data'); + lines.push(''); + for (const scenario of scenarios) { + const current = jsonReport.scenarios[scenario]; + lines.push(`### ${scenario}`); + lines.push(''); + lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) | Internal Marks |'); + lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|:--------------:|'); + const runs = current.rawRuns || []; + for (let i = 0; i < runs.length; i++) { + const r = runs[i]; + const round2 = (/** @type {number} */ v) => Math.round(v * 100) / 100; + lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${r.longAnimationFrameTotalMs !== null && r.longAnimationFrameTotalMs !== undefined ? round2(r.longAnimationFrameTotalMs) : '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} | ${r.hasInternalMarks ? 'yes' : 'no'} |`); + } + lines.push(''); + } + if (baseline) { + for (const scenario of scenarios) { + const base = baseline.scenarios?.[scenario]; + if (!base) { continue; } + lines.push(`### ${scenario} (baseline)`); + lines.push(''); + lines.push('| Run | TTFT (ms) | Complete (ms) | Layouts | Style Recalcs | LoAF Count | LoAF (ms) | Frames | Heap Delta (MB) | Internal Marks |'); + lines.push('|----:|----------:|--------------:|--------:|--------------:|-----------:|----------:|-------:|----------------:|:--------------:|'); + const runs = base.rawRuns || []; + for (let i = 0; i < runs.length; i++) { + const r = runs[i]; + const round2 = (/** @type {number} */ v) => Math.round(v * 100) / 100; + lines.push(`| ${i + 1} | ${round2(r.timeToFirstToken)} | ${r.timeToComplete} | ${r.layoutCount} | ${r.recalcStyleCount} | ${r.longAnimationFrameCount ?? '-'} | ${r.longAnimationFrameTotalMs !== null && r.longAnimationFrameTotalMs !== undefined ? round2(r.longAnimationFrameTotalMs) : '-'} | ${r.frameCount ?? '-'} | ${r.heapDelta} | ${r.hasInternalMarks ? 'yes' : 'no'} |`); + } + lines.push(''); + } + } + lines.push('
'); + lines.push(''); + + return lines.join('\n'); +} + +// -- Cleanup on SIGINT/SIGTERM ----------------------------------------------- + +/** @type {{ close: () => Promise } | null} */ +let activeVSCode = null; +/** @type {{ close: () => Promise } | null} */ +let activeMockServer = null; + +function installSignalHandlers() { + const cleanup = async () => { + console.log('\n[chat-simulation] Caught interrupt, cleaning up...'); + try { await activeVSCode?.close(); } catch { } + try { await activeMockServer?.close(); } catch { } + process.exit(130); + }; + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); +} + +// -- Main -------------------------------------------------------------------- + +async function main() { + registerPerfScenarios(); + const opts = parseArgs(); + + installSignalHandlers(); + + const { startServer } = require('./common/mock-llm-server'); + const mockServer = await startServer(0); + activeMockServer = mockServer; + console.log(`[chat-simulation] Mock LLM server: ${mockServer.url}`); + + // -- Resume mode -------------------------------------------------------- + if (opts.resume) { + if (!fs.existsSync(opts.resume)) { + console.error(`[chat-simulation] Resume file not found: ${opts.resume}`); + process.exit(1); + } + const prevResults = JSON.parse(fs.readFileSync(opts.resume, 'utf-8')); + const prevDir = path.dirname(opts.resume); + + // Find the associated baseline JSON in the same directory + const baselineFiles = fs.readdirSync(prevDir).filter((/** @type {string} */ f) => f.startsWith('baseline-') && f.endsWith('.json')); + const baselineFile = baselineFiles.length > 0 ? path.join(prevDir, baselineFiles[0]) : null; + const prevBaseline = baselineFile ? JSON.parse(fs.readFileSync(baselineFile, 'utf-8')) : null; + + // Determine which scenarios to resume (default: all from previous run) + const resumeScenarios = opts.scenarios.length > 0 + ? opts.scenarios.filter(s => prevResults.scenarios?.[s]) + : Object.keys(prevResults.scenarios || {}); + + if (resumeScenarios.length === 0) { + console.error('[chat-simulation] No matching scenarios found in previous results'); + process.exit(1); + } + + const testElectron = await resolveBuild(opts.build); + const baselineVersion = prevBaseline?.baselineBuildVersion; + const baselineElectron = baselineVersion ? await resolveBuild(baselineVersion) : null; + + const runsToAdd = opts.runs; + console.log(`[chat-simulation] Resuming from: ${opts.resume}`); + console.log(`[chat-simulation] Adding ${runsToAdd} runs per scenario`); + console.log(`[chat-simulation] Scenarios: ${resumeScenarios.join(', ')}`); + if (prevBaseline) { + console.log(`[chat-simulation] Baseline: ${baselineVersion} (${prevBaseline.scenarios?.[resumeScenarios[0]]?.rawRuns?.length || 0} existing runs)`); + } + console.log(''); + + for (const scenario of resumeScenarios) { + console.log(`[chat-simulation] === Resuming: ${scenario} ===`); + const prevTestRuns = prevResults.scenarios[scenario]?.rawRuns || []; + const prevBaseRuns = prevBaseline?.scenarios?.[scenario]?.rawRuns || []; + + // Run additional test iterations + console.log(`[chat-simulation] Test build (${prevTestRuns.length} existing + ${runsToAdd} new)`); + for (let i = 0; i < runsToAdd; i++) { + const runIdx = `${scenario}-resume-${prevTestRuns.length + i}`; + console.log(`[chat-simulation] Run ${i + 1}/${runsToAdd}...`); + try { + const m = await runOnce(testElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'test', { ...opts.settingsOverrides, ...opts.testSettingsOverrides }); + prevTestRuns.push(m); + if (opts.verbose) { + const src = m.hasInternalMarks ? 'internal' : 'client-side'; + console.log(` [${src}] firstToken=${m.timeToFirstToken}ms, complete=${m.timeToComplete}ms`); + } + } catch (err) { console.error(` Run ${i + 1} failed: ${err}`); } + } + + // Run additional baseline iterations + if (baselineElectron && prevBaseline?.scenarios?.[scenario]) { + console.log(`[chat-simulation] Baseline build (${prevBaseRuns.length} existing + ${runsToAdd} new)`); + for (let i = 0; i < runsToAdd; i++) { + const runIdx = `baseline-${scenario}-resume-${prevBaseRuns.length + i}`; + console.log(`[chat-simulation] Run ${i + 1}/${runsToAdd}...`); + try { + const m = await runOnce(baselineElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'baseline', { ...opts.settingsOverrides, ...opts.baselineSettingsOverrides }); + prevBaseRuns.push(m); + } catch (err) { console.error(` Run ${i + 1} failed: ${err}`); } + } + } + + // Recompute stats with merged data + const sd = /** @type {any} */ ({ runs: prevTestRuns.length, timing: {}, memory: {}, rendering: {}, extHost: {}, rawRuns: prevTestRuns }); + for (const [metric, group] of METRIC_DEFS) { sd[group][metric] = robustStats(prevTestRuns.map((/** @type {any} */ r) => r[metric])); } + prevResults.scenarios[scenario] = sd; + + if (prevBaseline?.scenarios?.[scenario]) { + const bsd = /** @type {any} */ ({ runs: prevBaseRuns.length, timing: {}, memory: {}, rendering: {}, extHost: {}, rawRuns: prevBaseRuns }); + for (const [metric, group] of METRIC_DEFS) { bsd[group][metric] = robustStats(prevBaseRuns.map((/** @type {any} */ r) => r[metric])); } + prevBaseline.scenarios[scenario] = bsd; + } + console.log(`[chat-simulation] Merged: test n=${prevTestRuns.length}${prevBaseRuns.length > 0 ? `, baseline n=${prevBaseRuns.length}` : ''}`); + console.log(''); + } + + // Write updated files back + prevResults.runsPerScenario = Math.max(prevResults.runsPerScenario || 0, ...Object.values(prevResults.scenarios).map((/** @type {any} */ s) => s.runs)); + prevResults.lastResumed = new Date().toISOString(); + fs.writeFileSync(opts.resume, JSON.stringify(prevResults, null, 2)); + console.log(`[chat-simulation] Updated results: ${opts.resume}`); + + if (prevBaseline && baselineFile) { + prevBaseline.lastResumed = new Date().toISOString(); + fs.writeFileSync(baselineFile, JSON.stringify(prevBaseline, null, 2)); + // Also update cached baseline + const cachedPath = path.join(DATA_DIR, path.basename(baselineFile)); + fs.writeFileSync(cachedPath, JSON.stringify(prevBaseline, null, 2)); + console.log(`[chat-simulation] Updated baseline: ${baselineFile}`); + } + + // -- Re-run comparison with merged data -------------------------------- + opts.baseline = baselineFile || undefined; + const jsonReport = prevResults; + jsonReport._resultsPath = opts.resume; + + // Fall through to comparison logic below + await printComparison(jsonReport, opts); + await mockServer.close(); + return; + } + + // -- Normal (non-resume) flow ------------------------------------------- + // --production-build: build a local bundled (non-dev) package from the + // current source tree using `gulp vscode`. This produces the same + // packaging as a release build (bundled JS, no VSCODE_DEV) while still + // testing your local changes. + if (opts.productionBuild && !opts.build) { + const prodBuildPath = buildProductionBuild(); + opts.build = prodBuildPath; + console.log(`[chat-simulation] --production-build: using local production build at ${prodBuildPath}`); + } + + const electronPath = await resolveBuild(opts.build); + + if (!fs.existsSync(electronPath)) { + console.error(`Electron not found at: ${electronPath}`); + console.error('Run "node build/lib/preLaunch.ts" first, or pass --build '); + process.exit(1); + } + + // Detect build modes for both test and baseline builds + const testBuildMode = detectBuildMode(electronPath); + + // Resolve the baseline build path early so we can detect its mode. + // For version strings this downloads; for local paths it resolves directly. + const isBaselineVersionString = opts.baselineBuild && isVersionString(opts.baselineBuild); + const isBaselineLocalPath = opts.baselineBuild && !isBaselineVersionString; + /** @type {string | undefined} */ + let baselineElectronPath; + if (isBaselineLocalPath) { + baselineElectronPath = await resolveBuild(opts.baselineBuild); + if (!fs.existsSync(baselineElectronPath)) { + console.error(`Baseline build not found at: ${baselineElectronPath}`); + process.exit(1); + } + } + const baselineBuildMode = opts.baselineBuild + ? (isBaselineVersionString ? 'release' : detectBuildMode(baselineElectronPath || '')) + : undefined; + + const isMismatchedBuildMode = baselineBuildMode !== undefined && testBuildMode !== baselineBuildMode; + + // Create a timestamped run directory for all output + const runTimestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const runDir = path.join(DATA_DIR, runTimestamp); + fs.mkdirSync(runDir, { recursive: true }); + console.log(`[chat-simulation] Output: ${runDir}`); + + // Compute effective settings per role + const testSettings = { ...opts.settingsOverrides, ...opts.testSettingsOverrides }; + const baselineSettings = { ...opts.settingsOverrides, ...opts.baselineSettingsOverrides }; + + // -- Baseline build -------------------------------------------------- + if (opts.baselineBuild) { + // Use a sanitized label for file names — replace path separators for local paths + const baselineLabel = isBaselineLocalPath + ? path.basename(path.resolve(opts.baselineBuild)) + : opts.baselineBuild; + const baselineJsonPath = path.join(runDir, `baseline-${baselineLabel}.json`); + + // Local paths: always run fresh (no caching — the build may have changed) + // Version strings: use caching as before + const cachedPath = isBaselineLocalPath ? null : path.join(DATA_DIR, `baseline-${baselineLabel}.json`); + const cachedBaseline = cachedPath && !opts.noCache && fs.existsSync(cachedPath) + ? JSON.parse(fs.readFileSync(cachedPath, 'utf-8')) + : null; + + if (cachedBaseline?.baselineBuildVersion === opts.baselineBuild) { + // Check if the cache covers all requested scenarios + const cachedScenarios = new Set(Object.keys(cachedBaseline.scenarios || {})); + const missingScenarios = opts.scenarios.filter((/** @type {string} */ s) => !cachedScenarios.has(s)); + + // Also check if cached scenarios have fewer runs than requested + const shortScenarios = opts.scenarios.filter((/** @type {string} */ s) => { + const cached = cachedBaseline.scenarios?.[s]; + return cached && (cached.rawRuns?.length || 0) < opts.runs; + }); + + if (missingScenarios.length === 0 && shortScenarios.length === 0) { + console.log(`[chat-simulation] Using cached baseline for ${opts.baselineBuild}`); + fs.writeFileSync(baselineJsonPath, JSON.stringify(cachedBaseline, null, 2)); + opts.baseline = baselineJsonPath; + } else { + const scenariosToRun = [...new Set([...missingScenarios, ...shortScenarios])]; + if (missingScenarios.length > 0) { + console.log(`[chat-simulation] Cached baseline missing scenarios: ${missingScenarios.join(', ')}`); + } + if (shortScenarios.length > 0) { + console.log(`[chat-simulation] Cached baseline needs more runs for: ${shortScenarios.map((/** @type {string} */ s) => `${s} (${cachedBaseline.scenarios[s].rawRuns?.length || 0}/${opts.runs})`).join(', ')}`); + } + console.log(`[chat-simulation] Running baseline for ${scenariosToRun.length} scenario(s)...`); + const baselineExePath = baselineElectronPath || await resolveBuild(opts.baselineBuild); + for (const scenario of scenariosToRun) { + const existingRuns = cachedBaseline.scenarios?.[scenario]?.rawRuns || []; + const runsNeeded = opts.runs - existingRuns.length; + /** @type {RunMetrics[]} */ + const newResults = []; + for (let i = 0; i < runsNeeded; i++) { + try { newResults.push(await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${existingRuns.length + i}`, runDir, 'baseline', baselineSettings)); } + catch (err) { console.error(`[chat-simulation] Baseline run ${i + 1} failed: ${err}`); } + } + const allRuns = [...existingRuns, ...newResults]; + if (allRuns.length > 0) { + const sd = /** @type {any} */ ({ runs: allRuns.length, timing: {}, memory: {}, rendering: {}, extHost: {}, rawRuns: allRuns }); + for (const [metric, group] of METRIC_DEFS) { sd[group][metric] = robustStats(allRuns.map((/** @type {any} */ r) => r[metric])); } + cachedBaseline.scenarios[scenario] = sd; + } + } + cachedBaseline.runsPerScenario = opts.runs; + fs.writeFileSync(baselineJsonPath, JSON.stringify(cachedBaseline, null, 2)); + if (cachedPath) { + fs.writeFileSync(cachedPath, JSON.stringify(cachedBaseline, null, 2)); + } + opts.baseline = baselineJsonPath; + } + } else { + const baselineExePath = baselineElectronPath || await resolveBuild(opts.baselineBuild); + console.log(`[chat-simulation] Benchmarking baseline build (${baselineLabel})...`); + /** @type {Record} */ + const baselineResults = {}; + for (const scenario of opts.scenarios) { + /** @type {RunMetrics[]} */ + const results = []; + for (let i = 0; i < opts.runs; i++) { + try { results.push(await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${i}`, runDir, 'baseline', baselineSettings)); } + catch (err) { console.error(`[chat-simulation] Baseline run ${i + 1} failed: ${err}`); } + } + if (results.length > 0) { baselineResults[scenario] = results; } + } + const baselineReport = { + timestamp: new Date().toISOString(), + baselineBuildVersion: opts.baselineBuild, + platform: process.platform, + runsPerScenario: opts.runs, + scenarios: /** @type {Record} */ ({}), + }; + for (const [scenario, results] of Object.entries(baselineResults)) { + const sd = /** @type {any} */ ({ runs: results.length, timing: {}, memory: {}, rendering: {}, extHost: {}, rawRuns: results }); + for (const [metric, group] of METRIC_DEFS) { sd[group][metric] = robustStats(results.map(r => /** @type {any} */(r)[metric])); } + baselineReport.scenarios[scenario] = sd; + } + fs.writeFileSync(baselineJsonPath, JSON.stringify(baselineReport, null, 2)); + // Cache at the top level for reuse across runs (version strings only) + if (cachedPath) { + fs.writeFileSync(cachedPath, JSON.stringify(baselineReport, null, 2)); + } + opts.baseline = baselineJsonPath; + } + console.log(''); + } + + // -- Run benchmarks -------------------------------------------------- + console.log(`[chat-simulation] Electron: ${electronPath}`); + console.log(`[chat-simulation] Build mode: ${buildModeLabel(testBuildMode)}`); + if (baselineBuildMode) { + console.log(`[chat-simulation] Baseline mode: ${buildModeLabel(baselineBuildMode)}`); + } + console.log(`[chat-simulation] Runs per scenario: ${opts.runs}`); + console.log(`[chat-simulation] Scenarios: ${opts.scenarios.join(', ')}`); + if (Object.keys(opts.settingsOverrides).length > 0) { + console.log(`[chat-simulation] Settings overrides (all): ${JSON.stringify(opts.settingsOverrides)}`); + } + if (Object.keys(opts.testSettingsOverrides).length > 0) { + console.log(`[chat-simulation] Settings overrides (test): ${JSON.stringify(opts.testSettingsOverrides)}`); + } + if (Object.keys(opts.baselineSettingsOverrides).length > 0) { + console.log(`[chat-simulation] Settings overrides (baseline): ${JSON.stringify(opts.baselineSettingsOverrides)}`); + } + + if (isMismatchedBuildMode) { + console.log(''); + console.log(`[chat-simulation] ⚠ WARNING: Build mode mismatch — test is ${testBuildMode}, baseline is ${baselineBuildMode}.`); + console.log('[chat-simulation] Results may not be directly comparable. For apples-to-apples'); + console.log('[chat-simulation] comparisons, use the same build type for both.'); + if (testBuildMode === 'dev') { + console.log('[chat-simulation] To use a local production build instead:'); + console.log('[chat-simulation] npm run perf:chat -- --production-build'); + } + if (!opts.ci && !opts.force) { + const readline = require('readline'); + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise(resolve => rl.question('[chat-simulation] Continue anyway? [y/N] ', resolve)); + rl.close(); + if (String(answer).toLowerCase() !== 'y') { + console.log('[chat-simulation] Aborted.'); + await mockServer.close(); + process.exit(0); + } + } + } + console.log(''); + + /** @type {Record} */ + const allResults = {}; + let anyFailed = false; + + for (const scenario of opts.scenarios) { + console.log(`[chat-simulation] === Scenario: ${scenario} ===`); + /** @type {RunMetrics[]} */ + const results = []; + for (let i = 0; i < opts.runs; i++) { + console.log(`[chat-simulation] Run ${i + 1}/${opts.runs}...`); + try { + const metrics = await runOnce(electronPath, scenario, mockServer, opts.verbose, `${scenario}-${i}`, runDir, 'test', testSettings); + results.push(metrics); + if (opts.verbose) { + const src = metrics.hasInternalMarks ? 'internal' : 'client-side'; + console.log(` [${src}] firstToken=${metrics.timeToFirstToken}ms, complete=${metrics.timeToComplete}ms, heap=delta${metrics.heapDelta}MB, longTasks=${metrics.longTaskCount}${metrics.hasInternalMarks ? `, internalTTFT=${metrics.internalFirstToken}ms` : ''}`); + } + } catch (err) { console.error(` Run ${i + 1} failed: ${err}`); } + } + if (results.length === 0) { console.error(`[chat-simulation] All runs failed for scenario: ${scenario}`); anyFailed = true; } + else { allResults[scenario] = results; } + console.log(''); + } + + // -- Summary --------------------------------------------------------- + console.log('[chat-simulation] ======================= Summary ======================='); + for (const [scenario, results] of Object.entries(allResults)) { + console.log(''); + console.log(` -- ${scenario} (${results.length} runs) --`); + console.log(''); + console.log(' Timing:'); + console.log(summarize(results.map(r => r.timeToFirstToken), ' Request → First token ', 'ms')); + console.log(summarize(results.map(r => r.timeToComplete), ' Request → Complete ', 'ms')); + console.log(summarize(results.map(r => r.timeToRenderComplete), ' Request → Rendered ', 'ms')); + console.log(''); + console.log(' Rendering:'); + console.log(summarize(results.map(r => r.layoutCount), ' Layouts ', '')); + console.log(summarize(results.map(r => r.layoutDurationMs), ' Layout duration ', 'ms')); + console.log(summarize(results.map(r => r.recalcStyleCount), ' Style recalcs ', '')); + console.log(summarize(results.map(r => r.forcedReflowCount), ' Forced reflows ', '')); + console.log(summarize(results.map(r => r.longTaskCount), ' Long tasks (>50ms) ', '')); + console.log(summarize(results.map(r => r.longAnimationFrameCount), ' Long anim. frames ', '')); + console.log(summarize(results.map(r => r.longAnimationFrameTotalMs), ' LoAF total duration ', 'ms')); + console.log(summarize(results.map(r => r.frameCount), ' Frames ', '')); + console.log(summarize(results.map(r => r.compositeLayers), ' Composite layers ', '')); + console.log(summarize(results.map(r => r.paintCount), ' Paints ', '')); + console.log(''); + console.log(' Memory:'); + console.log(summarize(results.map(r => r.heapDelta), ' Heap delta ', 'MB')); + console.log(summarize(results.map(r => r.heapDeltaPostGC), ' Heap delta (post-GC) ', 'MB')); + console.log(summarize(results.map(r => r.gcDurationMs), ' GC duration ', 'ms')); + if (results.some(r => r.extHostHeapDelta >= 0)) { + console.log(''); + console.log(' Extension Host:'); + console.log(summarize(results.map(r => r.extHostHeapUsedBefore), ' Heap before ', 'MB')); + console.log(summarize(results.map(r => r.extHostHeapUsedAfter), ' Heap after ', 'MB')); + console.log(summarize(results.map(r => r.extHostHeapDelta), ' Heap delta ', 'MB')); + console.log(summarize(results.map(r => r.extHostHeapDeltaPostGC), ' Heap delta (post-GC) ', 'MB')); + } + } + + // -- JSON output ----------------------------------------------------- + const jsonPath = path.join(runDir, 'results.json'); + const jsonReport = /** @type {{ timestamp: string, platform: NodeJS.Platform, runsPerScenario: number, buildMode: string, mismatchedBuildMode: boolean, scenarios: Record, _resultsPath?: string }} */ ({ + timestamp: new Date().toISOString(), + platform: process.platform, + runsPerScenario: opts.runs, + buildMode: testBuildMode, + mismatchedBuildMode: !!isMismatchedBuildMode, + scenarios: /** @type {Record} */ ({}), + }); + for (const [scenario, results] of Object.entries(allResults)) { + const sd = /** @type {any} */ ({ runs: results.length, timing: {}, memory: {}, rendering: {}, extHost: {}, rawRuns: results }); + for (const [metric, group] of METRIC_DEFS) { sd[group][metric] = robustStats(results.map(r => /** @type {any} */(r)[metric])); } + jsonReport.scenarios[scenario] = sd; + } + fs.writeFileSync(jsonPath, JSON.stringify(jsonReport, null, 2)); + jsonReport._resultsPath = jsonPath; + console.log(''); + console.log(`[chat-simulation] Results written to ${jsonPath}`); + + // -- Save baseline --------------------------------------------------- + if (opts.saveBaseline) { + if (!opts.baseline) { console.error('[chat-simulation] --save-baseline requires --baseline '); process.exit(1); } + fs.writeFileSync(opts.baseline, JSON.stringify(jsonReport, null, 2)); + console.log(`[chat-simulation] Baseline saved to ${opts.baseline}`); + } + + // -- Baseline comparison --------------------------------------------- + await printComparison(jsonReport, opts); + + if (anyFailed) { process.exit(1); } + await mockServer.close(); +} + +/** + * Print baseline comparison and exit with code 1 if regressions found. + * @param {Record} jsonReport + * @param {{ baseline?: string, threshold: number, ci?: boolean, runs?: number, baselineBuild?: string, build?: string, resume?: string, metricThresholds?: Record }} opts + */ +async function printComparison(jsonReport, opts) { + let regressionFound = false; + let inconclusiveFound = false; + if (opts.baseline && fs.existsSync(opts.baseline)) { + const baseline = JSON.parse(fs.readFileSync(opts.baseline, 'utf-8')); + console.log(''); + console.log(`[chat-simulation] =========== Baseline Comparison (threshold: ${(opts.threshold * 100).toFixed(0)}%) ===========`); + console.log(`[chat-simulation] Baseline: ${baseline.baselineBuildVersion || baseline.timestamp}`); + if (jsonReport.mismatchedBuildMode) { + console.log(`[chat-simulation] ⚠ Note: build mode mismatch — test is ${jsonReport.buildMode}, baseline differs.`); + console.log('[chat-simulation] Results may not be directly comparable.'); + } + console.log(''); + + // Metrics that trigger regression failure when they exceed the threshold + const regressionMetrics = [ + // [metric, group, unit] + ['timeToFirstToken', 'timing', 'ms'], + ['timeToComplete', 'timing', 'ms'], + ['layoutCount', 'rendering', ''], + ['recalcStyleCount', 'rendering', ''], + ['forcedReflowCount', 'rendering', ''], + ['longTaskCount', 'rendering', ''], + ]; + // Informational metrics — shown in comparison but don't trigger failure + const infoMetrics = [ + ['heapDelta', 'memory', 'MB'], + ['gcDurationMs', 'memory', 'ms'], + ['extHostHeapDelta', 'extHost', 'MB'], + ['extHostHeapDeltaPostGC', 'extHost', 'MB'], + ]; + + for (const scenario of Object.keys(jsonReport.scenarios)) { + const current = jsonReport.scenarios[scenario]; + const base = baseline.scenarios?.[scenario]; + if (!base) { console.log(` ${scenario}: (no baseline)`); continue; } + + /** @type {string[]} */ + const diffs = []; + let scenarioRegression = false; + + for (const [metric, group, unit] of regressionMetrics) { + const cur = current[group]?.[metric]; + const bas = base[group]?.[metric]; + if (!cur || !bas || !bas.median) { continue; } + const change = (cur.median - bas.median) / bas.median; + const pct = `${change > 0 ? '+' : ''}${(change * 100).toFixed(1)}%`; + + // Statistical significance via Welch's t-test on raw run values + const curRaw = (current.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + const basRaw = (base.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + const ttest = welchTTest(basRaw, curRaw); + + const metricThreshold = getMetricThreshold(opts, metric); + const absoluteDelta = cur.median - bas.median; + let flag = ''; + if (exceedsThreshold(metricThreshold, change, absoluteDelta)) { + if (!ttest) { + flag = ' ← possible regression (n too small for significance test)'; + inconclusiveFound = true; + } else if (ttest.significant) { + flag = ` ← REGRESSION (p=${ttest.pValue}, ${ttest.confidence} confidence)`; + scenarioRegression = true; + regressionFound = true; + } else { + flag = ` (likely noise — p=${ttest.pValue}, not significant)`; + inconclusiveFound = true; + } + } else if (ttest && change > 0 && ttest.significant && ttest.confidence === 'high') { + flag = ` (significant increase, p=${ttest.pValue})`; + } + diffs.push(` ${metric}: ${bas.median}${unit} → ${cur.median}${unit} (${pct})${flag}`); + } + for (const [metric, group, unit] of infoMetrics) { + const cur = current[group]?.[metric]; + const bas = base[group]?.[metric]; + if (!cur || !bas || bas.median === null || bas.median === undefined) { continue; } + const change = bas.median !== 0 ? (cur.median - bas.median) / bas.median : 0; + const pct = `${change > 0 ? '+' : ''}${(change * 100).toFixed(1)}%`; + diffs.push(` ${metric}: ${bas.median}${unit} → ${cur.median}${unit} (${pct}) [info]`); + } + console.log(` ${scenario}: ${scenarioRegression ? 'FAIL' : 'OK'}`); + diffs.forEach(d => console.log(d)); + } + + console.log(''); + console.log(regressionFound + ? `[chat-simulation] REGRESSION DETECTED — exceeded ${(opts.threshold * 100).toFixed(0)}% threshold with statistical significance` + : `[chat-simulation] All metrics within ${(opts.threshold * 100).toFixed(0)}% of baseline (or not statistically significant)`); + + if (inconclusiveFound && !regressionFound) { + // Find the results.json path to suggest in the hint + const resultsPath = Object.keys(jsonReport.scenarios).length > 0 + ? (jsonReport._resultsPath || opts.resume || 'path/to/results.json') + : 'path/to/results.json'; + // Estimate required runs from the observed effect size and variance + // using power analysis for Welch's t-test (alpha=0.05, 80% power). + // n_per_group = 2 * ((z_alpha/2 + z_beta) / d)^2 where d = Cohen's d + let maxNeeded = 0; + for (const scenario of Object.keys(jsonReport.scenarios)) { + const current = jsonReport.scenarios[scenario]; + const base = baseline.scenarios?.[scenario]; + if (!base) { continue; } + for (const [metric, group] of [['timeToFirstToken', 'timing'], ['timeToComplete', 'timing'], ['layoutCount', 'rendering'], ['recalcStyleCount', 'rendering']]) { + const curRaw = (current.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + const basRaw = (base.rawRuns || []).map((/** @type {any} */ r) => r[metric]).filter((/** @type {any} */ v) => v >= 0); + if (curRaw.length < 2 || basRaw.length < 2) { continue; } + const meanA = basRaw.reduce((/** @type {number} */ s, /** @type {number} */ v) => s + v, 0) / basRaw.length; + const meanB = curRaw.reduce((/** @type {number} */ s, /** @type {number} */ v) => s + v, 0) / curRaw.length; + const varA = basRaw.reduce((/** @type {number} */ s, /** @type {number} */ v) => s + (v - meanA) ** 2, 0) / (basRaw.length - 1); + const varB = curRaw.reduce((/** @type {number} */ s, /** @type {number} */ v) => s + (v - meanB) ** 2, 0) / (curRaw.length - 1); + const pooledSD = Math.sqrt((varA + varB) / 2); + if (pooledSD === 0) { continue; } + const d = Math.abs(meanB - meanA) / pooledSD; + if (d === 0) { continue; } + // z_0.025 = 1.96, z_0.2 = 0.842 + const nPerGroup = Math.ceil(2 * ((1.96 + 0.842) / d) ** 2); + const currentN = Math.min(curRaw.length, basRaw.length); + maxNeeded = Math.max(maxNeeded, nPerGroup - currentN); + } + } + const suggestedRuns = Math.max(1, Math.min(maxNeeded, 20)); + console.log(''); + console.log('[chat-simulation] Some metrics exceeded the threshold but were not statistically significant.'); + console.log('[chat-simulation] To increase confidence, add more runs with --resume:'); + console.log(`[chat-simulation] npm run perf:chat -- --resume ${resultsPath} --runs ${suggestedRuns}`); + } + } + + // -- CI summary ------------------------------------------------------ + if (opts.ci) { + const ciBaseline = opts.baseline && fs.existsSync(opts.baseline) + ? JSON.parse(fs.readFileSync(opts.baseline, 'utf-8')) + : null; + const summary = generateCISummary(jsonReport, ciBaseline, { + threshold: opts.threshold, + metricThresholds: opts.metricThresholds, + runs: jsonReport.runsPerScenario || opts.runs, + baselineBuild: ciBaseline?.baselineBuildVersion || opts.baselineBuild, + build: opts.build, + }); + + // Write to file for GitHub Actions $GITHUB_STEP_SUMMARY + const summaryPath = path.join(DATA_DIR, 'ci-summary.md'); + fs.writeFileSync(summaryPath, summary); + console.log(`[chat-simulation] CI summary written to ${summaryPath}`); + + // Also print the full summary table to stdout + console.log(''); + console.log('=================================================================='); + console.log(' CHAT PERF COMPARISON RESULTS '); + console.log('=================================================================='); + console.log(''); + console.log(summary); + } + + if (regressionFound) { process.exit(1); } +} + +main().catch(err => { console.error(err); process.exit(1); }); diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 5bfc2c65617dc..a352bdd7d61e0 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -238,7 +238,6 @@ export interface IProductConfiguration { readonly extensionConfigurationPolicy?: IStringDictionary; readonly onboardingKeymaps?: readonly IProductOnboardingKeymap[]; - readonly onboardingExtensions?: readonly IProductOnboardingExtension[]; readonly onboardingThemes?: readonly IProductOnboardingTheme[]; readonly embedded?: IEmbeddedProductConfiguration; @@ -262,14 +261,6 @@ export interface IProductOnboardingKeymap { readonly description: string; } -export interface IProductOnboardingExtension { - readonly id: string; - readonly name: string; - readonly publisher: string; - readonly description: string; - readonly icon: string; -} - export interface IProductOnboardingTheme { readonly id: string; readonly label: string; diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index fd75a516d4d84..3eb46b8d0f2ba 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -143,6 +143,7 @@ import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js' import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js'; import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../platform/networkFilter/common/networkFilterService.js'; +import { ITerminalSandboxService, NullTerminalSandboxService } from '../../platform/sandbox/common/terminalSandboxService.js'; import { CrossAppIPCService, ICrossAppIPCService } from '../../platform/crossAppIpc/electron-main/crossAppIpcService.js'; import ErrorTelemetry from '../../platform/telemetry/electron-main/errorTelemetry.js'; @@ -1109,6 +1110,7 @@ export class CodeApplication extends Disposable { services.set(IMeteredConnectionService, meteredConnectionService); // Web Contents Extractor + services.set(ITerminalSandboxService, new SyncDescriptor(NullTerminalSandboxService)); services.set(IAgentNetworkFilterService, new SyncDescriptor(AgentNetworkFilterService, undefined, true)); services.set(IWebContentExtractorService, new SyncDescriptor(NativeWebContentExtractorService, undefined, false /* proxied to other processes */)); diff --git a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts index 5d82638ec83e9..5857aecb01d00 100644 --- a/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts +++ b/src/vs/code/electron-utility/sharedProcess/sharedProcessMain.ts @@ -141,6 +141,7 @@ import { IMeteredConnectionService } from '../../../platform/meteredConnection/c import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js'; import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js'; import { AgentNetworkFilterService } from '../../../platform/networkFilter/common/networkFilterService.js'; +import { NullTerminalSandboxService } from '../../../platform/sandbox/common/terminalSandboxService.js'; import { ILocalGitService } from '../../../platform/git/common/localGitService.js'; import { LocalGitService } from '../../../platform/git/node/localGitService.js'; @@ -490,7 +491,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter { this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel); // Playwright - const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService))); + const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService), new NullTerminalSandboxService())); const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService), agentNetworkFilterService)); this.server.registerChannel('playwright', playwrightChannel); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index e45595c30187a..7116ca6c0b446 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -10,12 +10,12 @@ import type { IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; -import { IProtectedResourceMetadata, type IConfigSchema, type IModelSelection, type IToolDefinition } from './state/protocol/state.js'; -import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js'; import type { IAgentSubscription } from './state/agentSubscription.js'; import type { ICreateTerminalParams, IResolveSessionConfigResult, ISessionConfigCompletionsResult } from './state/protocol/commands.js'; +import { IProtectedResourceMetadata, type IConfigSchema, type IFileEdit, type IModelSelection, type IToolDefinition } from './state/protocol/state.js'; +import type { IActionEnvelope, INotification, ISessionAction, ITerminalAction } from './state/sessionActions.js'; import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { AttachmentType, ComponentToState, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent, type PolicyState, type StringOrMarkdown, SessionInputResponseKind } from './state/sessionState.js'; +import { AttachmentType, ComponentToState, SessionInputResponseKind, SessionStatus, StateComponents, type ICustomizationRef, type IPendingMessage, type IRootState, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -70,7 +70,7 @@ export interface IAgentSessionMetadata { readonly workingDirectory?: URI; readonly isRead?: boolean; readonly isDone?: boolean; - readonly diffs?: readonly { readonly uri: string; readonly added?: number; readonly removed?: number }[]; + readonly diffs?: readonly IFileEdit[]; } export interface IAgentSessionProjectInfo { @@ -296,6 +296,8 @@ export interface IAgentToolReadyEvent extends IAgentProgressEventBase { readonly permissionKind?: 'shell' | 'write' | 'mcp' | 'read' | 'url' | 'custom-tool'; /** File path associated with the permission request. */ readonly permissionPath?: string; + /** File edits this tool call will perform, for preview before confirmation. */ + readonly edits?: { items: IFileEdit[] }; } /** Streaming reasoning/thinking content from the assistant. */ diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index b51478e7d28dc..79967dc6f68a0 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -13c3475 +ab467b2 diff --git a/src/vs/platform/agentHost/node/agentEventMapper.ts b/src/vs/platform/agentHost/node/agentEventMapper.ts index 3a7985a6837b9..e662c38c8e3aa 100644 --- a/src/vs/platform/agentHost/node/agentEventMapper.ts +++ b/src/vs/platform/agentHost/node/agentEventMapper.ts @@ -155,6 +155,7 @@ export class AgentEventMapper { invocationMessage: e.invocationMessage, toolInput: e.toolInput, confirmationTitle: e.confirmationTitle, + edits: e.edits, ...(!e.confirmationTitle ? { confirmed: ToolCallConfirmationReason.NotNeeded } : {}), } satisfies IToolCallReadyAction; } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 12008b09dd954..848b51448bbed 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -44,6 +44,7 @@ import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js'; +import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; import { join } from '../../../base/common/path.js'; // Entry point for the agent host utility process. @@ -76,6 +77,9 @@ function startAgentHost(): void { // File service const fileService = disposables.add(new FileService(logService)); disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); + // In-memory filesystem backing transient file-edit previews shown during + // tool-call confirmations. + disposables.add(registerPendingEditContentProvider(fileService)); // Session data service const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 685480dfade77..7f6112007237b 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -47,6 +47,7 @@ import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { resolveServerUrls } from './serverUrls.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { IAgentPluginManager } from '../common/agentPluginManager.js'; +import { registerPendingEditContentProvider } from './copilot/pendingEditContentStore.js'; import { AgentHostGitService, IAgentHostGitService } from './agentHostGitService.js'; /** Log to stderr so messages appear in the terminal alongside the process. */ @@ -153,6 +154,9 @@ async function main(): Promise { // File service const fileService = disposables.add(new FileService(logService)); disposables.add(fileService.registerProvider(Schemas.file, disposables.add(new DiskFileSystemProvider(logService)))); + // In-memory filesystem backing transient file-edit previews shown during + // tool-call confirmations. + disposables.add(registerPendingEditContentProvider(fileService)); // Session data service const sessionDataService = new SessionDataService(URI.file(environmentService.userDataPath), fileService, logService); diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index b55d7d48a068f..1019b7ef6229c 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -3,23 +3,24 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import * as fs from 'fs'; +import { DeferredPromise, raceCancellablePromises, timeout } from '../../../base/common/async.js'; import { Emitter } from '../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { dirname } from '../../../base/common/path.js'; import * as platform from '../../../base/common/platform.js'; +import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; -import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { getShellIntegrationInjection } from '../../terminal/node/terminalEnvironment.js'; import { ActionType } from '../common/state/protocol/actions.js'; import type { ICreateTerminalParams } from '../common/state/protocol/commands.js'; import { ITerminalClaim, ITerminalContentPart, ITerminalInfo, ITerminalState, TerminalClaimKind } from '../common/state/protocol/state.js'; import { isTerminalAction } from '../common/state/sessionActions.js'; -import { getShellIntegrationInjection } from '../../terminal/node/terminalEnvironment.js'; -import { Osc633Event, Osc633EventType, Osc633Parser } from './osc633Parser.js'; import type { AgentHostStateManager } from './agentHostStateManager.js'; -import * as fs from 'fs'; -import { dirname } from '../../../base/common/path.js'; -import { DeferredPromise, raceCancellablePromises, timeout } from '../../../base/common/async.js'; +import { Osc633Event, Osc633EventType, Osc633Parser } from './osc633Parser.js'; const WAIT_FOR_PROMPT_TIMEOUT = 10_000; @@ -179,7 +180,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe const nodePty = await getNodePty(); - const cwd = params.cwd ?? process.cwd(); + const cwd = await this._resolveCwd(params.cwd, uri); const cols = params.cols ?? 80; const rows = params.rows ?? 24; @@ -646,6 +647,39 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe return process.env['SHELL'] || '/bin/sh'; } + /** + * Resolves the cwd string from {@link ICreateTerminalParams} to an + * accessible filesystem path, falling back to $HOME if the requested + * directory is missing (otherwise node-pty exits silently with code 1). + * Accepts either a `file://` URI string or a raw absolute filesystem path. + */ + private async _resolveCwd(cwd: string | undefined, terminalURI: string): Promise { + let resolved = cwd; + if (cwd) { + const parsed = URI.parse(cwd); + if (parsed.scheme === 'file' && parsed.fsPath && parsed.fsPath !== '/') { + resolved = parsed.fsPath; + } else { + this._logService.warn(`[TerminalManager] Ignoring non-file cwd for ${terminalURI}: ${cwd}`); + } + } + + try { + if (resolved) { + const stat = await fs.promises.stat(resolved); + if (stat.isDirectory()) { + return resolved; + } + } + } catch { + // fall through to fallback + } + + const fallback = process.env['HOME'] || process.env['USERPROFILE'] || process.cwd(); + this._logService.warn(`[TerminalManager] cwd '${resolved}' is not accessible, falling back to ${fallback}`); + return fallback; + } + /** Dispatch root/terminalsChanged with the current terminal list. */ private _broadcastTerminalList(): void { this._stateManager.dispatchServerAction({ diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 945e6d863fcf0..e59239a161c20 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -912,7 +912,7 @@ export class AgentSideEffects extends Disposable { } } - const diffs = await computeSessionDiffs(ref.object, this._diffComputeService, incremental); + const diffs = await computeSessionDiffs(session, ref.object, this._diffComputeService, incremental); this._stateManager.dispatchServerAction({ type: ActionType.SessionDiffsChanged, session, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 92d0ba6095b9a..05de458b78de1 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { CopilotClient, type SessionConfig } from '@github/copilot-sdk'; +import { CopilotClient, ResumeSessionConfig, type CopilotClientOptions, type SessionConfig } from '@github/copilot-sdk'; import { rgPath } from '@vscode/ripgrep'; import * as fs from 'fs/promises'; import { Limiter, SequencerByKey } from '../../../../base/common/async.js'; @@ -11,8 +11,8 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; import { FileAccess } from '../../../../base/common/network.js'; -import { observableValue } from '../../../../base/common/observable.js'; import { equals } from '../../../../base/common/objects.js'; +import { observableValue } from '../../../../base/common/observable.js'; import { basename, delimiter, dirname } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; @@ -50,6 +50,21 @@ interface ISerializedModelSelection { config?: unknown; } +/** + * Narrow surface of {@link CopilotClient} used by this provider. The SDK class + * has private members, so tests use this structural type to inject a fake. + */ +export interface ICopilotClient { + start(): Promise; + stop: CopilotClient['stop']; + listSessions: CopilotClient['listSessions']; + listModels: CopilotClient['listModels']; + createSession: CopilotClient['createSession']; + resumeSession: CopilotClient['resumeSession']; + getSessionMetadata: CopilotClient['getSessionMetadata']; + readonly rpc: { readonly sessions: { readonly fork: CopilotClient['rpc']['sessions']['fork'] } }; +} + function isReasoningEffort(value: string | undefined): value is ReasoningEffort { return ReasoningEfforts.some(reasoningEffort => reasoningEffort === value); } @@ -78,8 +93,8 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _models = observableValue(this, []); readonly models = this._models; - private _client: CopilotClient | undefined; - private _clientStarting: Promise | undefined; + private _client: ICopilotClient | undefined; + private _clientStarting: Promise | undefined; private _githubToken: string | undefined; private readonly _sessions = this._register(new DisposableMap()); private readonly _createdWorktrees = new Map(); @@ -101,12 +116,16 @@ export class CopilotAgent extends Disposable implements IAgent { this._plugins = this._instantiationService.createInstance(PluginController); } + protected _createCopilotClient(options: CopilotClientOptions): ICopilotClient { + return new CopilotClient(options); + } + // ---- auth --------------------------------------------------------------- getDescriptor(): IAgentDescriptor { return { provider: 'copilot', - displayName: 'Copilot', + displayName: 'Copilot CLI', description: 'Copilot SDK agent running in a dedicated process', }; } @@ -166,7 +185,7 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- client lifecycle --------------------------------------------------- - private async _ensureClient(): Promise { + private async _ensureClient(): Promise { const tokenAtStartup = this._githubToken; if (!tokenAtStartup) { throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot'); @@ -214,7 +233,7 @@ export class CopilotAgent extends Disposable implements IAgent { env[pathKey] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; this._logService.info(`[Copilot] Resolved CLI path: ${cliPath}`); - const client = new CopilotClient({ + const client = this._createCopilotClient({ githubToken: tokenAtStartup, useLoggedInUser: false, useStdio: true, @@ -319,24 +338,30 @@ export class CopilotAgent extends Disposable implements IAgent { const sessions = await client.listSessions(); const projectLimiter = new Limiter(4); const projectByContext = new Map>(); - const result: IAgentSessionMetadata[] = await Promise.all(sessions.map(async s => { + const mapped = await Promise.all(sessions.map(async s => { const session = AgentSession.uri(this.id, s.sessionId); const metadata = await this._readStoredSessionMetadata(session); + if (!metadata) { + return undefined; + } let { project, resolved } = metadata; if (!resolved) { project = await this._resolveSessionProject(s.context, projectLimiter, projectByContext); void this._storeSessionProjectResolution(session, project); } - return { + const workingDirectory = metadata.workingDirectory ?? (typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined); + const result: IAgentSessionMetadata = { session, startTime: s.startTime.getTime(), modifiedTime: s.modifiedTime.getTime(), - ...(project ? { project } : {}), + project, summary: s.summary, model: metadata.model, - workingDirectory: metadata.workingDirectory ?? (typeof s.context?.cwd === 'string' ? URI.file(s.context.cwd) : undefined), + workingDirectory, }; + return result; })); + const result = mapped.filter((s): s is IAgentSessionMetadata => s !== undefined); this._logService.info(`[Copilot] Found ${result.length} sessions`); return result; } @@ -754,7 +779,7 @@ export class CopilotAgent extends Disposable implements IAgent { * session's permission/hook callbacks, so it can be called lazily * inside the {@link SessionWrapperFactory}. */ - private _buildSessionConfig(snapshot: IActiveClientSnapshot | undefined, shellManager: ShellManager) { + private _buildSessionConfig(snapshot: IActiveClientSnapshot | undefined, shellManager: ShellManager): (args: Parameters[0]) => Promise { const shellTools = createShellTools(shellManager, this._terminalManager, this._logService); const plugins = snapshot?.plugins ?? []; @@ -927,10 +952,10 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: IModelSelection; workingDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean }> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: IModelSelection; workingDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { - return { resolved: false }; + return undefined; } try { const [model, cwd, resolved, uri, displayName] = await Promise.all([ @@ -940,8 +965,9 @@ export class CopilotAgent extends Disposable implements IAgent { ref.object.getMetadata(CopilotAgent._META_PROJECT_URI), ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME), ]); + const workingDirectory = cwd ? URI.parse(cwd) : undefined; const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined; - return { model: this._parseModelSelection(model), workingDirectory: cwd ? URI.parse(cwd) : undefined, project, resolved: resolved === 'true' || project !== undefined }; + return { model: this._parseModelSelection(model), workingDirectory, project, resolved: resolved === 'true' || project !== undefined }; } finally { ref.dispose(); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index dc983a533b75f..2a2dba720bb33 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -5,22 +5,25 @@ import type { PermissionRequestResult, SessionConfig, Tool, ToolResultObject } from '@github/copilot-sdk'; import { DeferredPromise } from '../../../../base/common/async.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import type { IParsedPlugin } from '../../../agentPlugins/common/pluginParsers.js'; +import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; -import type { IToolDefinition } from '../../common/state/protocol/state.js'; +import type { IFileEdit, IToolDefinition } from '../../common/state/protocol/state.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type IPendingMessage, type ISessionInputAnswer, type ISessionInputRequest, type IToolCallResult, type IToolResultContent } from '../../common/state/sessionState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import type { ShellManager } from './copilotShellTools.js'; import { getEditFilePath, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from './fileEditTracker.js'; import { mapSessionEvents } from './mapSessionEvents.js'; +import { buildPendingEditContentUri } from './pendingEditContentStore.js'; /** * Immutable snapshot of the active client's contributions at session creation @@ -110,6 +113,10 @@ export class CopilotAgentSession extends Disposable { private readonly _clientToolNames: ReadonlySet; /** Deferred promises for pending client tool calls, keyed by toolCallId. */ private readonly _pendingClientToolCalls = new Map>(); + /** `pending-edit-content:` URIs written during permission requests, keyed + * by toolCallId. Cleaned up when the permission resolves or the session + * is disposed. */ + private readonly _pendingEditContentUris = new Map(); private readonly _onDidSessionProgress: Emitter; private readonly _wrapperFactory: SessionWrapperFactory; @@ -120,6 +127,7 @@ export class CopilotAgentSession extends Disposable { @IInstantiationService private readonly _instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, @ISessionDataService sessionDataService: ISessionDataService, + @IFileService private readonly _fileService: IFileService, ) { super(); this.sessionId = options.rawSessionId; @@ -373,6 +381,21 @@ export class CopilotAgentSession extends Disposable { // Derive display information from the permission request kind const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request); + // For write permission requests, build an IFileEdit preview so the + // client can show a diff before the user approves or denies. This + // awaits async filesystem operations; the SDK already calls + // `handlePermissionRequest` from an arbitrary async context, so the + // extra await here is fine. + const edits = await this._buildEditsForPermission(request, toolCallId); + + // If the session was aborted/disposed while we were building the + // preview, the deferred has already been resolved and the + // `pending-edit-content:` entry has been cleaned up. Bail without + // firing tool_ready. + if (!this._pendingPermissions.has(toolCallId)) { + return { kind: 'denied-interactively-by-user' }; + } + // Fire a tool_ready event to transition the tool to PendingConfirmation this._onDidSessionProgress.fire({ session: this.sessionUri, @@ -383,6 +406,7 @@ export class CopilotAgentSession extends Disposable { confirmationTitle, permissionKind, permissionPath, + edits, }); const approved = await deferred.p; @@ -390,10 +414,73 @@ export class CopilotAgentSession extends Disposable { return { kind: approved ? 'approved' : 'denied-interactively-by-user' }; } + /** + * Builds an {@link IFileEdit} preview for a write permission request. + * + * The `before` side references the existing file on disk directly (if it + * exists); the `after` side is written to the `pending-edit-content:` + * in-memory filesystem so the client can fetch it via `resourceRead`. + * + * Returns `undefined` for permission kinds that don't describe file + * edits or when the request is missing the fields needed to build a + * preview. If the permission request is no longer pending by the time + * the in-memory write completes (e.g. the session was aborted), the + * just-written entry is deleted so it cannot leak. + */ + private async _buildEditsForPermission(request: ITypedPermissionRequest, toolCallId: string): Promise<{ items: IFileEdit[] } | undefined> { + if (request.kind !== 'write') { + return undefined; + } + const filePath = typeof request.fileName === 'string' ? request.fileName : undefined; + const newFileContents = typeof request.newFileContents === 'string' ? request.newFileContents : undefined; + if (!filePath || newFileContents === undefined) { + return undefined; + } + + const fileUri = URI.file(filePath); + const fileUriStr = fileUri.toString(); + + let beforeExists = false; + try { + beforeExists = await this._fileService.exists(fileUri); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to check file for edit preview: ${filePath}`, err); + } + + const afterUri = buildPendingEditContentUri(this.sessionUri.toString(), toolCallId, filePath); + try { + await this._fileService.writeFile(afterUri, VSBuffer.fromString(newFileContents)); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to write pending edit content for ${filePath}`, err); + return undefined; + } + + // If the request was already resolved (aborted/disposed) while we + // were awaiting the write, drop the in-memory entry immediately; + // `_deletePendingEditContent` has already run and won't run again. + if (!this._pendingPermissions.has(toolCallId)) { + this._fileService.del(afterUri).catch(err => { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to delete orphaned pending edit content: ${afterUri.toString()}`, err); + }); + return undefined; + } + this._pendingEditContentUris.set(toolCallId, afterUri); + + const diffCounts = typeof request.diff === 'string' ? countUnifiedDiffLines(request.diff) : undefined; + + const edit: IFileEdit = { + ...(beforeExists ? { before: { uri: fileUriStr, content: { uri: fileUriStr } } } : {}), + after: { uri: fileUriStr, content: { uri: afterUri.toString() } }, + ...(diffCounts ? { diff: diffCounts } : {}), + }; + return { items: [edit] }; + } + respondToPermissionRequest(requestId: string, approved: boolean): boolean { const deferred = this._pendingPermissions.get(requestId); if (deferred) { this._pendingPermissions.delete(requestId); + this._deletePendingEditContent(requestId); deferred.complete(approved); return true; } @@ -839,12 +926,28 @@ export class CopilotAgentSession extends Disposable { // ---- cleanup ------------------------------------------------------------ private _denyPendingPermissions(): void { - for (const [, deferred] of this._pendingPermissions) { + for (const [toolCallId, deferred] of this._pendingPermissions) { + this._deletePendingEditContent(toolCallId); deferred.complete(false); } this._pendingPermissions.clear(); } + /** + * Removes any `pending-edit-content:` entries associated with a resolved + * (approved, denied, or cancelled) permission request. + */ + private _deletePendingEditContent(toolCallId: string): void { + const uri = this._pendingEditContentUris.get(toolCallId); + if (!uri) { + return; + } + this._pendingEditContentUris.delete(toolCallId); + this._fileService.del(uri).catch(err => { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to delete pending edit content: ${uri.toString()}`, err); + }); + } + private _cancelPendingUserInputs(): void { for (const [, pending] of this._pendingUserInputs) { pending.deferred.complete({ response: SessionInputResponseKind.Cancel }); @@ -859,3 +962,26 @@ export class CopilotAgentSession extends Disposable { this._pendingClientToolCalls.clear(); } } + +/** + * Counts added/removed lines in a unified diff string. Ignores the `+++` and + * `---` header rows and any non-hunk context. + */ +function countUnifiedDiffLines(diff: string): { added: number; removed: number } | undefined { + let added = 0; + let removed = 0; + for (const line of diff.split('\n')) { + if (line.startsWith('+++') || line.startsWith('---')) { + continue; + } + if (line.startsWith('+')) { + added++; + } else if (line.startsWith('-')) { + removed++; + } + } + if (added === 0 && removed === 0) { + return undefined; + } + return { added, removed }; +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 50953bc3db8be..29edff4645e6c 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import type { IAgentToolReadyEvent } from '../../common/agentService.js'; import { StringOrMarkdown } from '../../common/state/protocol/state.js'; +import { basename } from '../../../../base/common/resources.js'; // ============================================================================= // Copilot CLI built-in tool interfaces @@ -160,7 +161,8 @@ function truncate(text: string, maxLength: number): string { * as a clickable file widget in the chat UI. */ function formatPathAsMarkdownLink(path: string): string { - return `[](${URI.file(path).toString()})`; + const uri = URI.file(path); + return `[${basename(uri)}](${uri})`; } /** @@ -400,6 +402,10 @@ export interface ITypedPermissionRequest extends PermissionRequest { toolName?: string; /** Tool arguments — set for `custom-tool` permission requests. */ args?: Record; + /** Unified diff of the proposed change — set for `write` permission requests. */ + diff?: string; + /** New file contents that will be written — set for `write` permission requests. */ + newFileContents?: string; } /** Safely extract a string value from an SDK field that may be `unknown` at runtime. */ diff --git a/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts b/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts new file mode 100644 index 0000000000000..08888a2d4bec4 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/pendingEditContentStore.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { encodeHex, VSBuffer } from '../../../../base/common/buffer.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IFileService } from '../../../files/common/files.js'; +import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; + +/** + * URI scheme for transient file content backing tool-call write-permission + * previews. Files under this scheme live in an in-memory provider registered + * on the agent host's file service; content can be read/written through the + * file service just like any other resource. + */ +export const PENDING_EDIT_CONTENT_SCHEME = 'pending-edit-content'; + +/** + * Builds a `pending-edit-content:` URI identifying the proposed "after" + * content for a write permission request. The authority is a hex-encoded + * session URI so multiple concurrent sessions don't collide. + */ +export function buildPendingEditContentUri(sessionUri: string, toolCallId: string, filePath: string): URI { + return URI.from({ + scheme: PENDING_EDIT_CONTENT_SCHEME, + authority: encodeHex(VSBuffer.fromString(sessionUri)).toString(), + path: `/${encodeURIComponent(toolCallId)}/${encodeHex(VSBuffer.fromString(filePath))}`, + }); +} + +/** + * Registers a fresh {@link InMemoryFileSystemProvider} for the + * `pending-edit-content:` scheme on the given file service. Callers use the + * returned disposable to unregister the provider. + */ +export function registerPendingEditContentProvider(fileService: IFileService): IDisposable { + const provider = new InMemoryFileSystemProvider(); + const registration = fileService.registerProvider(PENDING_EDIT_CONTENT_SCHEME, provider); + return { + dispose() { + registration.dispose(); + provider.dispose(); + }, + }; +} + diff --git a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts index dfcc9a2fdec2f..34082680d9549 100644 --- a/src/vs/platform/agentHost/node/sessionDiffAggregator.ts +++ b/src/vs/platform/agentHost/node/sessionDiffAggregator.ts @@ -7,16 +7,28 @@ import { URI } from '../../../base/common/uri.js'; import type { IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; import type { IDiffComputeService } from '../common/diffComputeService.js'; import { FileEditKind, type ISessionFileDiff } from '../common/state/sessionState.js'; +import { buildSessionDbUri } from './copilot/fileEditTracker.js'; function getFileEditUri(diff: ISessionFileDiff): string | undefined { return diff.after?.uri ?? diff.before?.uri; } -function createSessionFileDiff(identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { - const uri = URI.file(identity.terminalPath).toString(); - const content = { uri }; +function createSessionFileDiff(sessionUri: string, identity: IFileIdentity, added: number, removed: number): ISessionFileDiff { + const hasBefore = identity.firstKind !== FileEditKind.Create; + const hasAfter = identity.lastKind !== FileEditKind.Delete; return { - ...(identity.lastKind === FileEditKind.Delete ? { before: { uri, content } } : { after: { uri, content } }), + ...(hasBefore ? { + before: { + uri: URI.file(identity.firstFilePath).toString(), + content: { uri: buildSessionDbUri(sessionUri, identity.firstToolCallId, identity.firstFilePath, 'before') }, + }, + } : {}), + ...(hasAfter ? { + after: { + uri: URI.file(identity.terminalPath).toString(), + content: { uri: buildSessionDbUri(sessionUri, identity.lastToolCallId, identity.lastFilePath, 'after') }, + }, + } : {}), diff: { added, removed }, }; } @@ -69,6 +81,7 @@ export interface IIncrementalDiffOptions { * file and the total lines added/removed across the session. */ export async function computeSessionDiffs( + sessionUri: string, db: ISessionDatabase, diffService: IDiffComputeService, incremental?: IIncrementalDiffOptions, @@ -205,7 +218,7 @@ export async function computeSessionDiffs( } const counts = await diffService.computeDiffCounts(beforeText, afterText); - results.push(createSessionFileDiff(identity, counts.added, counts.removed)); + results.push(createSessionFileDiff(sessionUri, identity, counts.added, counts.removed)); })()); } diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 7dbb088fe6c05..40af0961605c8 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { FileService } from '../../../files/common/fileService.js'; @@ -14,12 +14,14 @@ import { InstantiationService } from '../../../instantiation/common/instantiatio import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { AgentSession, type IAgentSessionMetadata } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ISessionCustomization, ICustomizationRef } from '../../common/state/sessionState.js'; import { IAgentHostGitService } from '../../node/agentHostGitService.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; -import { CopilotAgent, getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot } from '../../node/copilot/copilotAgent.js'; +import { CopilotAgent, getCopilotWorktreeBranchName, getCopilotWorktreeName, getCopilotWorktreesRoot, type ICopilotClient } from '../../node/copilot/copilotAgent.js'; +import { SessionDatabase } from '../../node/sessionDatabase.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; class TestAgentPluginManager implements IAgentPluginManager { @@ -62,28 +64,119 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getTerminalState(): undefined { return undefined; } } -function createTestAgent(disposables: DisposableStore): CopilotAgent { +class TestSessionDataService extends Disposable implements ISessionDataService { + declare readonly _serviceBrand: undefined; + + private readonly _databases = new Map(); + readonly openedSessions: string[] = []; + + getSessionDataDir(session: URI): URI { return URI.from({ scheme: 'test', path: `/session-data/${AgentSession.id(session)}` }); } + getSessionDataDirById(sessionId: string): URI { return URI.from({ scheme: 'test', path: `/session-data/${sessionId}` }); } + + openDatabase(session: URI): IReference { + const sessionId = AgentSession.id(session); + this.openedSessions.push(sessionId); + let db = this._databases.get(sessionId); + if (!db) { + db = this._register(new SessionDatabase(':memory:')); + this._databases.set(sessionId, db); + } + return { object: db, dispose: () => { } }; + } + + async tryOpenDatabase(session: URI): Promise | undefined> { + const db = this._databases.get(AgentSession.id(session)); + return db ? { object: db, dispose: () => { } } : undefined; + } + + deleteSessionData(): Promise { return Promise.resolve(); } + cleanupOrphanedData(): Promise { return Promise.resolve(); } +} + +class TestCopilotClient implements ICopilotClient { + readonly rpc: ICopilotClient['rpc'] = { sessions: { fork: async () => ({ sessionId: 'forked-session' }) } }; + + constructor( + private readonly _sessions: Awaited>, + ) { } + + async start(): Promise { } + async stop(): ReturnType { return []; } + async listSessions(): ReturnType { return this._sessions; } + async listModels(): ReturnType { return []; } + async getSessionMetadata(): ReturnType { return undefined; } + createSession: ICopilotClient['createSession'] = async () => { throw new Error('not implemented'); }; + resumeSession: ICopilotClient['resumeSession'] = async () => { throw new Error('not implemented'); }; +} + +class TestableCopilotAgent extends CopilotAgent { + constructor( + private readonly _copilotClient: ICopilotClient, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + @IFileService fileService: IFileService, + @ISessionDataService sessionDataService: ISessionDataService, + @IAgentHostGitService gitService: IAgentHostGitService, + @IAgentHostTerminalManager terminalManager: IAgentHostTerminalManager, + ) { + super(logService, instantiationService, fileService, sessionDataService, gitService, terminalManager); + } + + protected override _createCopilotClient(): ICopilotClient { + return this._copilotClient; + } +} + +function createTestAgent(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ICopilotClient }): CopilotAgent { const services = new ServiceCollection(); const logService = new NullLogService(); const fileService = disposables.add(new FileService(logService)); services.set(ILogService, logService); services.set(IFileService, fileService); - services.set(ISessionDataService, createNullSessionDataService()); + services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); services.set(IAgentPluginManager, new TestAgentPluginManager()); services.set(IAgentHostGitService, new TestAgentHostGitService()); services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); services.set(IInstantiationService, instantiationService); + if (options?.copilotClient) { + return instantiationService.createInstance(TestableCopilotAgent, options.copilotClient); + } return instantiationService.createInstance(CopilotAgent); } +function withoutUndefinedProperties(metadata: IAgentSessionMetadata): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +function sdkSession(sessionId: string, cwd?: string): Awaited>[number] { + return { + sessionId, + startTime: new Date(1000), + modifiedTime: new Date(2000), + summary: `SDK ${sessionId}`, + isRemote: false, + ...(cwd ? { context: { cwd } } : {}), + }; +} + async function disposeAgent(agent: CopilotAgent): Promise { + await agent.shutdown(); agent.dispose(); + // CopilotAgent.dispose calls super.dispose() from a promise continuation so + // async shutdown can stop SDK sessions before child disposables are released. + // Let that continuation run before the disposable leak tracker checks. await Promise.resolve(); } suite('CopilotAgent', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); test('uses the Copilot CLI sibling worktrees root convention', () => { assert.strictEqual( @@ -106,19 +199,16 @@ suite('CopilotAgent', () => { }); test('returns empty models and sessions before authentication', async () => { - const disposables = new DisposableStore(); const agent = createTestAgent(disposables); try { assert.deepStrictEqual(agent.models.get(), []); assert.deepStrictEqual(await agent.listSessions(), []); } finally { await disposeAgent(agent); - disposables.dispose(); } }); test('requires authentication before creating a session', async () => { - const disposables = new DisposableStore(); const agent = createTestAgent(disposables); try { await assert.rejects( @@ -127,7 +217,59 @@ suite('CopilotAgent', () => { ); } finally { await disposeAgent(agent); - disposables.dispose(); + } + }); + + test('listSessions only returns sessions with a database', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const ownedSession = AgentSession.uri('copilot', 'owned'); + const ownedDb = sessionDataService.openDatabase(ownedSession); + ownedDb.dispose(); + + const client = new TestCopilotClient([sdkSession('owned'), sdkSession('external')]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + assert.deepStrictEqual((await agent.listSessions()).map(s => AgentSession.id(s.session)), ['owned']); + } finally { + await disposeAgent(agent); + } + }); + + test('listSessions reads stored metadata from sessions with a database', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const legacySession = AgentSession.uri('copilot', 'legacy'); + const legacyDb = sessionDataService.openDatabase(legacySession); + await legacyDb.object.setMetadata('copilot.workingDirectory', URI.file('/workspace').toString()); + legacyDb.dispose(); + + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('legacy')]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + assert.deepStrictEqual((await agent.listSessions()).map(withoutUndefinedProperties), [{ + session: legacySession, + startTime: 1000, + modifiedTime: 2000, + summary: 'SDK legacy', + workingDirectory: URI.file('/workspace'), + }]); + } finally { + await disposeAgent(agent); + } + }); + + test('listSessions does not create databases for unowned SDK sessions', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('external', '/workspace')]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + assert.deepStrictEqual(await agent.listSessions(), []); + assert.deepStrictEqual(sessionDataService.openedSessions, []); + } finally { + await disposeAgent(agent); } }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 4b364714656bf..9edab34493e96 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -3,21 +3,22 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import assert from 'assert'; import type { CopilotSession, SessionEvent, SessionEventPayload, SessionEventType, ToolResultObject, TypedSessionEventHandler } from '@github/copilot-sdk'; +import assert from 'assert'; +import { DeferredPromise } from '../../../../base/common/async.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService, ILogService } from '../../../log/common/log.js'; import { IFileService } from '../../../files/common/files.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession, IAgentProgressEvent, IAgentUserInputRequestEvent } from '../../common/agentService.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { CopilotAgentSession, IActiveClientSnapshot, SessionWrapperFactory } from '../../node/copilot/copilotAgentSession.js'; import { CopilotSessionWrapper } from '../../node/copilot/copilotSessionWrapper.js'; -import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; -import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; // ---- Mock CopilotSession (SDK level) ---------------------------------------- @@ -82,10 +83,31 @@ async function createAgentSession(disposables: DisposableStore, options?: { clie session: CopilotAgentSession; mockSession: MockCopilotSession; progressEvents: IAgentProgressEvent[]; + waitForProgress: (predicate: (event: IAgentProgressEvent) => boolean) => Promise; }> { const progressEmitter = disposables.add(new Emitter()); const progressEvents: IAgentProgressEvent[] = []; - disposables.add(progressEmitter.event(e => progressEvents.push(e))); + const waiters: { predicate: (event: IAgentProgressEvent) => boolean; deferred: DeferredPromise }[] = []; + disposables.add(progressEmitter.event(e => { + progressEvents.push(e); + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i].predicate(e)) { + const { deferred } = waiters[i]; + waiters.splice(i, 1); + deferred.complete(e); + } + } + })); + + const waitForProgress = (predicate: (event: IAgentProgressEvent) => boolean): Promise => { + const existing = progressEvents.find(predicate); + if (existing) { + return Promise.resolve(existing); + } + const deferred = new DeferredPromise(); + waiters.push({ predicate, deferred }); + return deferred.p; + }; const sessionUri = AgentSession.uri('copilot', 'test-session-1'); const mockSession = new MockCopilotSession(); @@ -113,7 +135,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { clie await session.initializeSession(); - return { session, mockSession, progressEvents }; + return { session, mockSession, progressEvents, waitForProgress }; } // ---- Tests ------------------------------------------------------------------ @@ -130,15 +152,15 @@ suite('CopilotAgentSession', () => { suite('permission handling', () => { test('read permission fires tool_ready (deferred to side effects)', async () => { - const { session, progressEvents } = await createAgentSession(disposables); + const { session, progressEvents, waitForProgress } = await createAgentSession(disposables); const resultPromise = session.handlePermissionRequest({ kind: 'read', path: '/workspace/src/file.ts', toolCallId: 'tc-1', }); + await waitForProgress(e => e.type === 'tool_ready'); assert.strictEqual(progressEvents.length, 1); - assert.strictEqual(progressEvents[0].type, 'tool_ready'); assert.ok(session.respondToPermissionRequest('tc-1', true)); const result = await resultPromise; @@ -146,15 +168,15 @@ suite('CopilotAgentSession', () => { }); test('write permission fires tool_ready (deferred to side effects)', async () => { - const { session, progressEvents } = await createAgentSession(disposables); + const { session, progressEvents, waitForProgress } = await createAgentSession(disposables); const resultPromise = session.handlePermissionRequest({ kind: 'write', fileName: '/workspace/src/file.ts', toolCallId: 'tc-1', }); + await waitForProgress(e => e.type === 'tool_ready'); assert.strictEqual(progressEvents.length, 1); - assert.strictEqual(progressEvents[0].type, 'tool_ready'); assert.ok(session.respondToPermissionRequest('tc-1', true)); const result = await resultPromise; @@ -162,7 +184,7 @@ suite('CopilotAgentSession', () => { }); test('write permission outside working directory fires tool_ready', async () => { - const { session, progressEvents } = await createAgentSession(disposables); + const { session, progressEvents, waitForProgress } = await createAgentSession(disposables); const resultPromise = session.handlePermissionRequest({ kind: 'write', @@ -170,8 +192,8 @@ suite('CopilotAgentSession', () => { toolCallId: 'tc-write-outside', }); + await waitForProgress(e => e.type === 'tool_ready'); assert.strictEqual(progressEvents.length, 1); - assert.strictEqual(progressEvents[0].type, 'tool_ready'); assert.ok(session.respondToPermissionRequest('tc-write-outside', true)); const result = await resultPromise; @@ -179,7 +201,7 @@ suite('CopilotAgentSession', () => { }); test('read permission outside working directory fires tool_ready', async () => { - const { session, progressEvents } = await createAgentSession(disposables); + const { session, progressEvents, waitForProgress } = await createAgentSession(disposables); // Kick off permission request but don't await — it will block const resultPromise = session.handlePermissionRequest({ @@ -189,8 +211,8 @@ suite('CopilotAgentSession', () => { }); // Should have fired a tool_ready event + await waitForProgress(e => e.type === 'tool_ready'); assert.strictEqual(progressEvents.length, 1); - assert.strictEqual(progressEvents[0].type, 'tool_ready'); // Respond to it assert.ok(session.respondToPermissionRequest('tc-2', true)); @@ -205,12 +227,13 @@ suite('CopilotAgentSession', () => { }); test('denied-interactively when user denies', async () => { - const { session, progressEvents } = await createAgentSession(disposables); + const { session, progressEvents, waitForProgress } = await createAgentSession(disposables); const resultPromise = session.handlePermissionRequest({ kind: 'shell', toolCallId: 'tc-3', }); + await waitForProgress(e => e.type === 'tool_ready'); assert.strictEqual(progressEvents.length, 1); session.respondToPermissionRequest('tc-3', false); const result = await resultPromise; @@ -578,7 +601,7 @@ suite('CopilotAgentSession', () => { }); test('permission request consumes pending auto-ready for client tools', async () => { - const { session, mockSession, progressEvents } = await createAgentSession(disposables, { clientSnapshot: snapshot }); + const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables, { clientSnapshot: snapshot }); // SDK emits tool.execution_start — tool_start fires immediately mockSession.fire('tool.execution_start', { @@ -600,6 +623,7 @@ suite('CopilotAgentSession', () => { }); // tool_ready from permission flow should have fired (with confirmationTitle) + await waitForProgress(e => e.type === 'tool_ready'); const toolReadys = progressEvents.filter(e => e.type === 'tool_ready'); assert.strictEqual(toolReadys.length, 1); if (toolReadys[0].type === 'tool_ready') { @@ -704,7 +728,7 @@ suite('CopilotAgentSession', () => { }); test('tool_start stores pending auto-ready data for client tools', async () => { - const { session, mockSession, progressEvents } = await createAgentSession(disposables, { clientSnapshot: snapshot }); + const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables, { clientSnapshot: snapshot }); mockSession.fire('tool.execution_start', { toolCallId: 'tc-ready-data', @@ -726,6 +750,7 @@ suite('CopilotAgentSession', () => { toolName: 'my_tool', }); + await waitForProgress(e => e.type === 'tool_ready'); const toolReadys = progressEvents.filter(e => e.type === 'tool_ready'); assert.strictEqual(toolReadys.length, 1); if (toolReadys[0].type === 'tool_ready') { diff --git a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts index d5432b5dc558e..f58a762ea87e3 100644 --- a/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDiffAggregator.test.ts @@ -9,6 +9,9 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { FileEditKind, type ISessionFileDiff } from '../../common/state/sessionState.js'; import { encodeString, TestDiffComputeService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { computeSessionDiffs } from '../../node/sessionDiffAggregator.js'; +import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; + +const TEST_SESSION_URI = 'session://test-session'; const createTestDiffService = () => new TestDiffComputeService(); @@ -21,6 +24,24 @@ function getDiffUri(diff: ISessionFileDiff): string | undefined { return diff.after?.uri ?? diff.before?.uri; } +interface ISimpleDiff { + uri: string | undefined; + added: number; + removed: number; +} + +function simplify(diff: ISessionFileDiff): ISimpleDiff { + return { + uri: getDiffUri(diff), + added: diff.diff?.added ?? 0, + removed: diff.diff?.removed ?? 0, + }; +} + +function simpleDiff(path: string, added: number, removed: number): ISimpleDiff { + return { uri: URI.file(path).toString(), added, removed }; +} + suite('computeSessionDiffs', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -30,7 +51,7 @@ suite('computeSessionDiffs', () => { test('returns empty array for no edits', async () => { const db = new TestSessionDatabase(); const diffService = createTestDiffService(); - const result = await computeSessionDiffs(db, diffService); + const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); assert.deepStrictEqual(result, []); }); @@ -43,12 +64,76 @@ suite('computeSessionDiffs', () => { }); const diffService = createTestDiffService(); - const result = await computeSessionDiffs(db, diffService); + const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); - assert.deepStrictEqual(result, [fileDiff('/a.txt', 1, 0)]); + assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]); assert.strictEqual(diffService.callCount, 1); }); + test('populates before/after with session-db content URIs for edits', async () => { + const db = new TestSessionDatabase(); + db.addEdit({ + turnId: 't1', toolCallId: 'tc1', filePath: '/a.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('v1'), afterContent: encodeString('v2'), + }); + db.addEdit({ + turnId: 't2', toolCallId: 'tc2', filePath: '/a.txt', kind: FileEditKind.Edit, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('v2'), afterContent: encodeString('v3'), + }); + + const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService()); + + assert.strictEqual(result.length, 1); + const [diff] = result; + const fileUri = URI.file('/a.txt').toString(); + assert.strictEqual(diff.before?.uri, fileUri); + assert.strictEqual(diff.after?.uri, fileUri); + + // before content points to the FIRST snapshot (tc1) + const beforeFields = parseSessionDbUri(diff.before!.content.uri); + assert.deepStrictEqual(beforeFields, { + sessionUri: TEST_SESSION_URI, + toolCallId: 'tc1', + filePath: '/a.txt', + part: 'before', + }); + + // after content points to the LAST snapshot (tc2) + const afterFields = parseSessionDbUri(diff.after!.content.uri); + assert.deepStrictEqual(afterFields, { + sessionUri: TEST_SESSION_URI, + toolCallId: 'tc2', + filePath: '/a.txt', + part: 'after', + }); + }); + + test('omits before for creates and after for deletes', async () => { + const db = new TestSessionDatabase(); + db.addEdit({ + turnId: 't1', toolCallId: 'tc1', filePath: '/created.txt', kind: FileEditKind.Create, + addedLines: undefined, removedLines: undefined, + afterContent: encodeString('new'), + }); + db.addEdit({ + turnId: 't1', toolCallId: 'tc2', filePath: '/deleted.txt', kind: FileEditKind.Delete, + addedLines: undefined, removedLines: undefined, + beforeContent: encodeString('bye'), + }); + + const result = await computeSessionDiffs(TEST_SESSION_URI, db, createTestDiffService()); + result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); + + assert.strictEqual(result.length, 2); + const [created, deleted] = result; + assert.strictEqual(created.before, undefined, 'create has no before'); + assert.ok(created.after, 'create has after'); + assert.ok(deleted.before, 'delete has before'); + assert.strictEqual(deleted.after, undefined, 'delete has no after'); + }); + test('skips files with no net change', async () => { const db = new TestSessionDatabase(); db.addEdit({ @@ -63,7 +148,7 @@ suite('computeSessionDiffs', () => { }); const diffService = createTestDiffService(); - const result = await computeSessionDiffs(db, diffService); + const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); // Before = tc1.before = 'same', After = tc2.after = 'same' → zero net change assert.deepStrictEqual(result, []); @@ -84,7 +169,7 @@ suite('computeSessionDiffs', () => { }); const diffService = createTestDiffService(); - const result = await computeSessionDiffs(db, diffService); + const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); assert.strictEqual(result.length, 1); assert.strictEqual(getDiffUri(result[0]), URI.file('/b.txt').toString(), 'uses terminal path after rename'); @@ -113,6 +198,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, @@ -121,9 +207,9 @@ suite('computeSessionDiffs', () => { // Sort to ensure stable comparison result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); - assert.deepStrictEqual(result, [ - fileDiff('/a.txt', 42, 7), // carried over - fileDiff('/b.txt', 1, 0), // recomputed + assert.deepStrictEqual(result.map(simplify), [ + simpleDiff('/a.txt', 42, 7), // carried over + simpleDiff('/b.txt', 1, 0), // recomputed ]); // Only file B should have triggered a diff computation assert.strictEqual(diffService.callCount, 1, 'only touched file should be diffed'); @@ -149,13 +235,14 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, ); // Should compare tc1.before='original' vs tc2.after='after-turn2\nextra' - assert.deepStrictEqual(result, [fileDiff('/a.txt', 1, 0)]); + assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]); assert.strictEqual(diffService.callCount, 1); }); @@ -181,6 +268,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, @@ -211,6 +299,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, @@ -242,6 +331,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, @@ -266,7 +356,7 @@ suite('computeSessionDiffs', () => { }); const diffService = createTestDiffService(); - const result = await computeSessionDiffs(db, diffService); + const result = await computeSessionDiffs(TEST_SESSION_URI, db, diffService); assert.strictEqual(result.length, 2); assert.strictEqual(diffService.callCount, 2, 'both files should be diffed in full mode'); @@ -295,6 +385,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, @@ -305,9 +396,9 @@ suite('computeSessionDiffs', () => { assert.strictEqual(db.getAllFileEditsCalls, 0, 'fast path should not call getAllFileEdits'); result.sort((a, b) => (getDiffUri(a) ?? '').localeCompare(getDiffUri(b) ?? '')); - assert.deepStrictEqual(result, [ - fileDiff('/new.txt', 1, 0), - fileDiff('/old.txt', 3, 1), // carried over + assert.deepStrictEqual(result.map(simplify), [ + simpleDiff('/new.txt', 1, 0), + simpleDiff('/old.txt', 3, 1), // carried over ]); }); @@ -332,6 +423,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, @@ -342,7 +434,7 @@ suite('computeSessionDiffs', () => { assert.strictEqual(db.getAllFileEditsCalls, 1, 'should fall back to getAllFileEdits'); // Cumulative diff: original → turn2\nextra - assert.deepStrictEqual(result, [fileDiff('/a.txt', 1, 0)]); + assert.deepStrictEqual(result.map(simplify), [simpleDiff('/a.txt', 1, 0)]); }); test('incremental slow path: rename in current turn falls back to getAllFileEdits', async () => { @@ -365,6 +457,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, @@ -387,6 +480,7 @@ suite('computeSessionDiffs', () => { const diffService = createTestDiffService(); const result = await computeSessionDiffs( + TEST_SESSION_URI, db, diffService, { changedTurnId: 't2', previousDiffs }, diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 41173e99c2c85..ecb40a9e856c0 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -326,8 +326,9 @@ export interface IBrowserViewService { /** * Focus the browser view * @param id The browser view identifier + * @param force Whether to force focus even if the view's window is not focused. */ - focus(id: string): Promise; + focus(id: string, force?: boolean): Promise; /** * Find text in the browser view's page diff --git a/src/vs/platform/browserView/common/cdp/proxy.ts b/src/vs/platform/browserView/common/cdp/proxy.ts index 86b3f4af1a50d..aeb5bfc10055c 100644 --- a/src/vs/platform/browserView/common/cdp/proxy.ts +++ b/src/vs/platform/browserView/common/cdp/proxy.ts @@ -6,7 +6,7 @@ import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { ICDPTarget, CDPRequest, CDPResponse, CDPEvent, CDPError, CDPErrorCode, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, CDPTargetInfo, ICDPBrowserTarget } from './types.js'; +import { ICDPTarget, CDPRequest, CDPResponse, CDPEvent, CDPError, CDPErrorCode, CDPServerError, CDPMethodNotFoundError, CDPInvalidParamsError, ICDPConnection, ICDPBrowserTarget } from './types.js'; /** * CDP protocol handler for browser-level connections. @@ -15,19 +15,25 @@ import { ICDPTarget, CDPRequest, CDPResponse, CDPEvent, CDPError, CDPErrorCode, */ export class CDPBrowserProxy extends Disposable implements ICDPConnection { readonly sessionId = `browser-session-${generateUuid()}`; + get targetId() { + return this.browserTarget.targetInfo.targetId; + } // Browser session state private _isAttachedToBrowserTarget = false; private _autoAttach = false; private _discover = false; - private readonly _targets = this._register(new TargetManager()); - - // sessionId -> ICDPConnection (keyed by real session ID from target) + /** + * All sessions known to this proxy, keyed by sessionId. + * Includes sessions from explicit attach, proxy auto-attach, + * and client auto-attach children. + */ private readonly _sessions = this._register(new DisposableMap()); - private readonly _sessionTargetIds = new WeakMap(); + private readonly _targets = this._register(new DisposableMap()); + // Only auto-attach once per target. - private readonly _autoAttachments = new WeakMap>(); + private readonly _autoAttachments = new WeakSet(); // CDP method handlers map private readonly _handlers = new Map Promise | object>([ @@ -50,7 +56,7 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { ['Target.disposeBrowserContext', (p) => this.handleTargetDisposeBrowserContext(p as { browserContextId: string })], ['Target.getBrowserContexts', () => this.handleTargetGetBrowserContexts()], ['Target.getTargets', () => this.handleTargetGetTargets()], - ['Target.setAutoAttach', (p) => this.handleTargetSetAutoAttach(p as { autoAttach?: boolean; flatten?: boolean })], + ['Target.setAutoAttach', (p, s) => this.handleTargetSetAutoAttach(p as { autoAttach?: boolean; flatten?: boolean }, s)], ['Target.setDiscoverTargets', (p) => this.handleTargetSetDiscoverTargets(p as { discover?: boolean })], ['Target.attachToBrowserTarget', () => this.handleTargetAttachToBrowserTarget()], ['Target.getTargetInfo', (p) => this.handleTargetGetTargetInfo(p as { targetId?: string } | undefined)], @@ -60,46 +66,106 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { private readonly browserTarget: ICDPBrowserTarget, ) { super(); + } - this._targets.onDidRegisterTarget(async ({ targetInfo }) => { + registerTarget(target: ICDPTarget): void { + const targetInfo = target.targetInfo; + if (this._targets.has(targetInfo.targetId)) { + return; + } + this._targets.set(targetInfo.targetId, target); + + if (this._discover) { + this.sendEvent('Target.targetCreated', { + targetInfo: target.targetInfo, + }); + } + if (this._autoAttach && !this._autoAttachments.has(target)) { + this._autoAttachments.add(target); + void target.attach(); + } + + target.onClose(() => { + this._targets.deleteAndDispose(targetInfo.targetId); if (this._discover) { - this.sendBrowserEvent('Target.targetCreated', { targetInfo }); - } - if (this._autoAttach) { - await this.attachToTarget(targetInfo.targetId, true); + this.sendEvent('Target.targetDestroyed', { targetId: targetInfo.targetId }); } }); - this._targets.onDidUnregisterTarget(({ targetInfo }) => { - // Close any sessions attached to the destroyed target. Snapshot first - // to avoid mutating _sessions while iterating (onClose fires synchronously). - const toDispose: ICDPConnection[] = []; - for (const [, connection] of this._sessions) { - if (this._sessionTargetIds.get(connection) === targetInfo.targetId) { - toDispose.push(connection); - } - } - for (const connection of toDispose) { - connection.dispose(); - } + target.onTargetInfoChanged(info => { if (this._discover) { - this.sendBrowserEvent('Target.targetDestroyed', { targetId: targetInfo.targetId }); + this.sendEvent('Target.targetInfoChanged', { targetInfo: info }); } }); - // Subscribe to browser target events - this._register(this.browserTarget.onTargetCreated(target => this._targets.register(target))); - this._register(this.browserTarget.onTargetDestroyed(target => this._targets.unregister(target))); + for (const [, session] of target.sessions) { + this.registerSession(session, false); + } + target.onSessionCreated(({ session, waitingForDebugger }) => { + this.registerSession(session, waitingForDebugger); + }); + } - // Register existing targets - for (const target of this.browserTarget.getTargets()) { - void this._targets.register(target); + notifySessionCreated(session: ICDPConnection, waitingForDebugger: boolean): void { + if (this._sessions.has(session.sessionId)) { + return; // We already know about it. + } + if (!session.parentSessionId) { + return; // Created globally -- we don't care about it. } + if (!this._sessions.has(session.parentSessionId)) { + return; // Not from one of our sessions -- ignore it. + } + const target = this._targets.get(session.targetId); + if (!target) { + return; // Target isn't known -- ignore it. + } + target.notifySessionCreated(session, waitingForDebugger); + } - // Mirror typed events to the onMessage channel - this._register(this._onEvent.event(event => { - this._onMessage.fire(event); - })); + private registerSession(session: ICDPConnection, waitingForDebugger: boolean): void { + if (this._sessions.has(session.sessionId)) { + return; + } + this._sessions.set(session.sessionId, session); + + const target = this._targets.get(session.targetId); + if (!target) { + throw new CDPServerError(`Unable to resolve target for session ${session.sessionId}`); + } + + this.sendEvent('Target.attachedToTarget', { + sessionId: session.sessionId, + targetInfo: target.targetInfo, + waitingForDebugger + }, session.parentSessionId); + + // Forward non-Target events from the session to the external client. + // Target domain events are suppressed — the proxy emits its own + // lifecycle events (attachedToTarget, detachedFromTarget, etc.) + // via registerSession / onClose / sendEvent. + session.onEvent(event => { + if (event.method.startsWith('Target.')) { + return; + } + this.sendEvent(event.method, event.params, event.sessionId ?? session.sessionId); + }); + + session.onClose(() => { + this._sessions.deleteAndDispose(session.sessionId); + + this.sendEvent('Target.detachedFromTarget', { + sessionId: session.sessionId, + targetId: session.targetId + }, session.parentSessionId); + }); + } + + /** Send a browser-level event to the client */ + private sendEvent(method: string, params: unknown, sessionId?: string): void { + sessionId ||= (this._isAttachedToBrowserTarget ? this.sessionId : undefined); + this._onMessage.fire({ method, params, sessionId }); + this._onEvent.fire({ method, params, sessionId }); } // #region Public API @@ -174,12 +240,16 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { // #region CDP Commands private handleBrowserGetWindowForTarget({ targetId }: { targetId?: string }, sessionId?: string) { - const resolvedTargetId = (sessionId && this.findTargetIdForSession(sessionId)) ?? targetId; + const resolvedTargetId = (sessionId && this._sessions.get(sessionId)?.targetId) ?? targetId; if (!resolvedTargetId) { throw new CDPServerError('Unable to resolve target'); } - const target = this._targets.getById(resolvedTargetId); + const target = this._targets.get(resolvedTargetId); + if (!target) { + throw new CDPServerError('Unable to resolve target'); + } + return this.browserTarget.getWindowForTarget(target); } @@ -198,22 +268,38 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } private handleTargetAttachToBrowserTarget() { + this.sendEvent('Target.attachedToTarget', { + sessionId: this.sessionId, + targetInfo: this.browserTarget.targetInfo, + waitingForDebugger: false + }); this._isAttachedToBrowserTarget = true; return { sessionId: this.sessionId }; } private handleTargetActivateTarget({ targetId }: { targetId: string }) { - const target = this._targets.getById(targetId); + const target = this._targets.get(targetId); + if (!target) { + throw new CDPServerError('Unable to resolve target'); + } return this.browserTarget.activateTarget(target); } - private async handleTargetSetAutoAttach({ autoAttach = false, flatten }: { autoAttach?: boolean; flatten?: boolean }) { - if (!flatten) { + private async handleTargetSetAutoAttach(params: { autoAttach?: boolean; flatten?: boolean }, sessionId?: string) { + if (sessionId && sessionId !== this.sessionId) { + const connection = this._sessions.get(sessionId); + if (!connection) { + throw new CDPServerError(`Session not found: ${sessionId}`); + } + return connection.sendCommand('Target.setAutoAttach', params); + } + + if (!params.flatten) { throw new CDPInvalidParamsError('This implementation only supports auto-attach with flatten=true'); } - // Note: auto-attach only attaches to new targets, not to existing ones. - this._autoAttach = autoAttach; + // Proxy-level auto-attach: attach to new targets as they are registered. + this._autoAttach = params.autoAttach ?? false; return {}; } @@ -224,8 +310,8 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { if (this._discover) { // Announce all existing targets - for (const targetInfo of this._targets.getAllInfos()) { - this.sendBrowserEvent('Target.targetCreated', { targetInfo }); + for (const target of this._targets.values()) { + this.sendEvent('Target.targetCreated', { targetInfo: target.targetInfo }); } } } @@ -234,17 +320,20 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } private async handleTargetGetTargets() { - return { targetInfos: Array.from(this._targets.getAllInfos()) }; + return { targetInfos: Array.from(this._targets.values()).map(target => target.targetInfo) }; } private async handleTargetGetTargetInfo({ targetId }: { targetId?: string } = {}) { if (!targetId) { // No targetId specified -- return info about the browser target itself - return { targetInfo: await this.browserTarget.getTargetInfo() }; + return { targetInfo: this.browserTarget.targetInfo }; } - const target = this._targets.getById(targetId); - return { targetInfo: await target.getTargetInfo() }; + const target = this._targets.get(targetId); + if (!target) { + throw new CDPServerError('Unable to resolve target'); + } + return { targetInfo: target.targetInfo }; } private async handleTargetAttachToTarget({ targetId, flatten }: { targetId: string; flatten?: boolean }) { @@ -252,7 +341,11 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { throw new CDPInvalidParamsError('This implementation only supports attachToTarget with flatten=true'); } - const connection = await this.attachToTarget(targetId, false); + const target = this._targets.get(targetId); + if (!target) { + throw new CDPServerError('Unable to resolve target'); + } + const connection = await target.attach(); return { sessionId: connection.sessionId }; } @@ -268,19 +361,24 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { private async handleTargetCreateTarget({ url, browserContextId }: { url?: string; browserContextId?: string }) { const target = await this.browserTarget.createTarget(url || 'about:blank', browserContextId); - const targetInfo = await this._targets.register(target); + this.registerTarget(target); // Playwright expects the attachment to happen before createTarget returns. - if (this._autoAttach) { - await this.attachToTarget(targetInfo.targetId, true); + if (this._autoAttach && !this._autoAttachments.has(target)) { + this._autoAttachments.add(target); + await target.attach(); } - return { targetId: targetInfo.targetId }; + return { targetId: target.targetInfo.targetId }; } private async handleTargetCloseTarget({ targetId }: { targetId: string }) { try { - await this.browserTarget.closeTarget(this._targets.getById(targetId)); + const target = this._targets.get(targetId); + if (!target) { + throw new CDPServerError('Unable to resolve target'); + } + await this.browserTarget.closeTarget(target); return { success: true }; } catch { return { success: false }; @@ -288,152 +386,4 @@ export class CDPBrowserProxy extends Disposable implements ICDPConnection { } // #endregion - - // #region Internal Helpers - - /** Find the targetId for a given sessionId */ - private findTargetIdForSession(sessionId: string): string | undefined { - const connection = this._sessions.get(sessionId); - if (!connection) { - return undefined; - } - return this._sessionTargetIds.get(connection); - } - - /** Send a browser-level event to the client */ - private sendBrowserEvent(method: string, params: unknown): void { - const sessionId = this._isAttachedToBrowserTarget ? this.sessionId : undefined; - this._onEvent.fire({ method, params, sessionId }); - } - - /** Attach to a target, creating a named session */ - private async attachToTarget(targetId: string, isAutoAttach: boolean): Promise { - const target = this._targets.getById(targetId); - if (isAutoAttach) { - if (this._autoAttachments.has(target)) { - return this._autoAttachments.get(target)!; - } - } - - const attachmentPromise = (async () => { - const connection = await target.attach(); - const sessionId = connection.sessionId; - - this._sessions.set(sessionId, connection); - this._sessionTargetIds.set(connection, targetId); - - const targetInfo = await target.getTargetInfo(); - - // Forward non-Target.* events to the external client, tagged with the sessionId. - connection.onEvent(event => { - if (!event.method.startsWith('Target.')) { - this._onEvent.fire({ - method: event.method, - params: event.params, - sessionId - }); - } - }); - connection.onClose(() => { - this.sendBrowserEvent('Target.detachedFromTarget', { sessionId, targetId }); - this._sessions.deleteAndDispose(sessionId); - this._sessionTargetIds.delete(connection); - - if (this._autoAttachments.get(target) === attachmentPromise) { - this._autoAttachments.delete(target); - } - }); - - this.sendBrowserEvent('Target.attachedToTarget', { - sessionId, - targetInfo: { ...targetInfo, attached: true }, - - // Normally this would be configured by the client in `Target.setAutoAttach`, - // but Electron doesn't allow us to control this, so we hardcode it to false. - waitingForDebugger: false - }); - - return connection; - })(); - - if (isAutoAttach) { - this._autoAttachments.set(target, attachmentPromise); - } - - return attachmentPromise; - } - - // #endregion -} - -/** - * Getting target info is an asynchronous operation, but we want to avoid emitting duplicate events - * if the same target object is registered multiple times before getTargetInfo resolves. - * - * This class manages that deduplication and maintains the mapping between target objects and their resolved target info. - */ -class TargetManager extends Disposable { - // Synchronous dedup: tracks target objects we have already started processing. - private readonly _knownTargets = new WeakSet(); - // target object -> targetInfo (populated async after getTargetInfo) - private readonly _targetInfos = new WeakMap(); - // targetId -> target object (reverse lookup, populated alongside _targetInfos) - private readonly _targetsByID = new Map(); - - private readonly _onDidRegisterTarget = this._register(new Emitter<{ target: ICDPTarget; targetInfo: CDPTargetInfo }>()); - readonly onDidRegisterTarget: Event<{ target: ICDPTarget; targetInfo: CDPTargetInfo }> = this._onDidRegisterTarget.event; - private readonly _onDidUnregisterTarget = this._register(new Emitter<{ target: ICDPTarget; targetInfo: CDPTargetInfo }>()); - readonly onDidUnregisterTarget: Event<{ target: ICDPTarget; targetInfo: CDPTargetInfo }> = this._onDidUnregisterTarget.event; - - getById(targetId: string): ICDPTarget { - const target = this._targetsByID.get(targetId); - if (!target) { - throw new CDPServerError(`Unknown targetId: ${targetId}`); - } - return target; - } - - *getAllInfos(): IterableIterator { - for (const target of this._targetsByID.values()) { - yield this._targetInfos.get(target)!; - } - } - - async register(target: ICDPTarget): Promise { - // Synchronous dedup - if this target object was already seen, just - // return its info without emitting duplicate events. - if (this._knownTargets.has(target)) { - return target.getTargetInfo(); - } - this._knownTargets.add(target); - - // Resolve the targetId asynchronously - const targetInfo = await target.getTargetInfo(); - if (!this._knownTargets.has(target)) { - // Target was unregistered before getTargetInfo resolved. Don't register or emit events. - return targetInfo; - } - - this._targetInfos.set(target, targetInfo); - this._targetsByID.set(targetInfo.targetId, target); - - // Emit creation event - this._onDidRegisterTarget.fire({ target, targetInfo }); - - return targetInfo; - } - - async unregister(target: ICDPTarget): Promise { - if (!this._knownTargets.has(target)) { - return; - } - this._knownTargets.delete(target); - - const targetInfo = this._targetInfos.get(target); - if (targetInfo) { - this._targetInfos.delete(target); - this._targetsByID.delete(targetInfo.targetId); - this._onDidUnregisterTarget.fire({ target, targetInfo }); - } - } } diff --git a/src/vs/platform/browserView/common/cdp/types.ts b/src/vs/platform/browserView/common/cdp/types.ts index 6fbfd30e26f9b..5bae195c85ad3 100644 --- a/src/vs/platform/browserView/common/cdp/types.ts +++ b/src/vs/platform/browserView/common/cdp/types.ts @@ -126,30 +126,36 @@ export interface CDPWindowBounds { * A debuggable CDP target (e.g., a browser view). * Targets can be attached to by CDP clients. */ -export interface ICDPTarget { - /** Get target info for CDP protocol. Initializes the target if needed. */ - getTargetInfo(): Promise; - /** Attach to receive events and send commands. Initializes if needed. Dispose to detach. */ +export interface ICDPTarget extends IDisposable { + /** Get target info for CDP protocol. */ + targetInfo: CDPTargetInfo; + /** Fired when target info changes. */ + readonly onTargetInfoChanged: Event; + + /** Attach to receive events and send commands. Dispose to detach. */ attach(): Promise; + + /** All active sessions on this target. */ + readonly sessions: ReadonlyMap; + /** Fired when a new session is created on this target. */ + readonly onSessionCreated: Event<{ session: ICDPConnection; waitingForDebugger: boolean }>; + /** Can be called to notify the target that a new session has been created for it. */ + notifySessionCreated(session: ICDPConnection, waitingForDebugger: boolean): void; + + /** Fired when this target is closed or disposed. */ + readonly onClose: Event; } /** * Service interface for managing CDP targets and browser contexts. */ export interface ICDPBrowserTarget extends ICDPTarget { - /** Event fired when a target is created */ - readonly onTargetCreated: Event; - /** Event fired when a target is about to be destroyed */ - readonly onTargetDestroyed: Event; // Browser-level information /** Get browser version info for CDP Browser.getVersion */ getVersion(): CDPBrowserVersion; /** Get the window ID and bounds for a target */ getWindowForTarget(target: ICDPTarget): { windowId: number; bounds: CDPWindowBounds }; - - /** Get all available targets */ - getTargets(): IterableIterator; /** Create a new target in the specified browser context */ createTarget(url: string, browserContextId?: string, windowId?: number): Promise; /** Activate a target (bring to foreground) */ @@ -172,8 +178,12 @@ export interface ICDPBrowserTarget extends ICDPTarget { * implemented by CDPBrowserProxy for the full protocol-aware connection. */ export interface ICDPConnection extends IDisposable { + /** Optional parent session ID of this connection, if created via a specific session */ + readonly parentSessionId?: string; /** The session ID for this connection */ readonly sessionId: string; + /** The target ID this session is attached to */ + readonly targetId: string; /** Event fired when the connection receives a CDP event */ readonly onEvent: Event; diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 01393a72d991b..2472e8bba1573 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -16,7 +16,6 @@ import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-mai import { BrowserViewUri } from '../common/browserViewUri.js'; import { BrowserViewDebugger } from './browserViewDebugger.js'; import { ILogService } from '../../log/common/log.js'; -import { ICDPTarget, ICDPConnection, CDPTargetInfo } from '../common/cdp/types.js'; import { BrowserSession } from './browserSession.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { hasKey } from '../../../base/common/types.js'; @@ -26,7 +25,7 @@ import { SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.j * Represents a single browser view instance with its WebContentsView and all associated logic. * This class encapsulates all operations and events for a single browser view. */ -export class BrowserView extends Disposable implements ICDPTarget { +export class BrowserView extends Disposable { private readonly _view: WebContentsView; private readonly _faviconRequestCache = new Map>(); @@ -36,7 +35,7 @@ export class BrowserView extends Disposable implements ICDPTarget { private _lastUserGestureTimestamp: number = -Infinity; private _browserZoomIndex: number = browserZoomDefaultIndex; - private readonly _debugger: BrowserViewDebugger; + readonly debugger: BrowserViewDebugger; private readonly _inspector: BrowserViewElementInspector; private _window: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; @@ -155,7 +154,7 @@ export class BrowserView extends Disposable implements ICDPTarget { this.dispose(); }); - this._debugger = new BrowserViewDebugger(this, this.logService); + this.debugger = new BrowserViewDebugger(this, this.logService); this._inspector = this._register(new BrowserViewElementInspector(this)); this.setupEventListeners(); @@ -322,7 +321,7 @@ export class BrowserView extends Disposable implements ICDPTarget { const pageIsAvailable = this._view.getVisible() && !webContents.isCrashed() - && !this._debugger.isPaused; + && !this.debugger.isPaused; if (pageIsAvailable) { return; } @@ -595,7 +594,11 @@ export class BrowserView extends Disposable implements ICDPTarget { /** * Focus this view */ - async focus(): Promise { + async focus(force?: boolean): Promise { + // By default, only focus the view if its window is already focused. + if (!force && !this._window?.win?.isFocused()) { + return; + } this._view.webContents.focus(); } @@ -684,23 +687,6 @@ export class BrowserView extends Disposable implements ICDPTarget { return this._window && hasKey(this._window, { parentId: true }) ? this._codeWindowById(this._window.parentId) : undefined; } - // ============ ICDPTarget implementation ============ - - /** - * Get CDP target info using Electron's real targetId. - */ - getTargetInfo(): Promise { - return this._debugger.getTargetInfo(); - } - - /** - * Attach to receive debugger events. - * @returns A connection that can be disposed to detach - */ - attach(): Promise { - return this._debugger.attach(); - } - override dispose(): void { if (this._isDisposed) { return; @@ -708,7 +694,7 @@ export class BrowserView extends Disposable implements ICDPTarget { this._isDisposed = true; // Dispose debugger. This detaches debug sessions first. - this._debugger.dispose(); + this.debugger.dispose(); // Remove from parent window this._window?.win?.contentView.removeChildView(this._view); diff --git a/src/vs/platform/browserView/electron-main/browserViewCDPTarget.ts b/src/vs/platform/browserView/electron-main/browserViewCDPTarget.ts new file mode 100644 index 0000000000000..42653e9af1d47 --- /dev/null +++ b/src/vs/platform/browserView/electron-main/browserViewCDPTarget.ts @@ -0,0 +1,108 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CDPTargetInfo, ICDPConnection, ICDPTarget } from '../common/cdp/types.js'; +import type { BrowserView } from './browserView.js'; + +/** + * Wraps a {@link BrowserViewDebugger} transport as an {@link ICDPTarget}, + * tracking sessions and forwarding target-info changes for a single + * CDP target (page, worker, iframe, etc.). + */ +export class BrowserViewCDPTarget extends Disposable implements ICDPTarget { + protected readonly _sessions = new Map(); + get sessions(): ReadonlyMap { return this._sessions; } + + private readonly _onSessionCreated = this._register(new Emitter<{ session: ICDPConnection; waitingForDebugger: boolean }>()); + readonly onSessionCreated = this._onSessionCreated.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + + private readonly _onTargetInfoChanged = this._register(new Emitter()); + readonly onTargetInfoChanged = this._onTargetInfoChanged.event; + + private _isDisposed = false; + + constructor( + readonly view: BrowserView, + protected readonly _targetInfo: CDPTargetInfo + ) { + super(); + + this._register(this.view.debugger.onTargetInfoChanged(info => { + if (info.targetId !== this._targetInfo.targetId) { + return; + } + + if (info.title !== this._targetInfo.title || info.url !== this._targetInfo.url) { + this._targetInfo.title = info.title; + this._targetInfo.url = info.url; + this._onTargetInfoChanged.fire(this.targetInfo); + } + })); + + this._register(this.view.debugger.onTargetDestroyed(targetId => { + if (targetId === this._targetInfo.targetId) { + this.dispose(); + } + })); + } + + get targetInfo(): CDPTargetInfo { + return { + ...this._targetInfo, + attached: this._sessions.size > 0, + browserContextId: this.view.session.id + }; + } + + async attach(): Promise { + const session = await this.view.debugger.attachToTarget(this.targetInfo.targetId); + this.notifySessionCreated(session, false); + return session; + } + + notifySessionCreated(session: ICDPConnection, waitingForDebugger: boolean): void { + if (this._sessions.has(session.sessionId)) { + return; + } + if (this.sessions.size === 0) { + // First session attached, update target info to reflect attached state. + this._onTargetInfoChanged.fire(this.targetInfo); + } + + this._sessions.set(session.sessionId, session); + session.onClose(() => { + this._sessions.delete(session.sessionId); + if (this.sessions.size === 0) { + // Last session detached, update target info to reflect detached state. + this._onTargetInfoChanged.fire(this.targetInfo); + } + }); + + this._onSessionCreated.fire({ session, waitingForDebugger }); + } + + override dispose(): void { + if (this._isDisposed) { + return; + } + this._isDisposed = true; + + // Dispose owned sessions. + for (const [, session] of this._sessions) { + session.dispose(); + } + this._sessions.clear(); + + // Signal target closure. + this._onClose.fire(); + + super.dispose(); + } +} diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index a2379fccf2810..413d2eaceaa65 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -6,43 +6,63 @@ import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; -import { CDPEvent, CDPTargetInfo, ICDPConnection, ICDPTarget } from '../common/cdp/types.js'; +import { CDPEvent, CDPTargetInfo, ICDPConnection } from '../common/cdp/types.js'; import { BrowserView } from './browserView.js'; /** - * Wraps a browser view's Electron debugger with per-client session management. + * CDP transport for a browser view, backed by the Electron debugger. * - * Each client gets their own Electron debugger session, providing true isolation - * just like connecting multiple DevTools clients to a real Chrome instance. + * Manages: + * - Electron debugger lifecycle (attach/detach) + * - Session registry and event routing + * - Auto-attach via `Target.setAutoAttach` (flatten=true) + * + * All CDP sessions on this WebContents (root + child sessions) live in a + * single flat registry here. Target-level abstractions (sub-target + * discovery, {@link ICDPTarget} contract) are handled by + * {@link BrowserViewCDPTarget} which wraps this class. */ -export class BrowserViewDebugger extends Disposable implements ICDPTarget { +export class BrowserViewDebugger extends Disposable { - /** Map from CDP sessionId to the per-connection event emitter */ private readonly _sessions = this._register(new DisposableMap()); + private readonly _onSessionCreated = this._register(new Emitter<{ session: ICDPConnection; waitingForDebugger: boolean }>()); + readonly onSessionCreated = this._onSessionCreated.event; + + /** + * Target IDs discovered via `Target.attachedToTarget`. Consumed by + * {@link BrowserViewCDPTarget} to create sub-target handles. + */ + private readonly _knownTargets = new Map(); + get knownTargets(): ReadonlyMap { return this._knownTargets; } + + private readonly _onTargetDiscovered = this._register(new Emitter()); + /** Fired when a new targetId is seen in an attachedToTarget event. */ + readonly onTargetDiscovered = this._onTargetDiscovered.event; + + private readonly _onTargetDestroyed = this._register(new Emitter()); + /** Fired when a targetId is removed via a targetDestroyed event. */ + readonly onTargetDestroyed = this._onTargetDestroyed.event; + + private readonly _onTargetInfoChanged = this._register(new Emitter()); + /** Fired when targetInfo for a known target changes (e.g. title/url update). */ + readonly onTargetInfoChanged = this._onTargetInfoChanged.event; /** Whether any attached debugger session has paused JavaScript execution. */ private _isPaused = false; get isPaused(): boolean { return this._isPaused; } - /** - * The real CDP targetId discovered from Target.getTargets(). - * Ideally this could be fetched synchronously from the WebContents, - * but in practice we need to query Electron's debugger API asynchronously to find it. - */ - private _realTargetId: string | undefined; - private _initializePromise: Promise | undefined; private readonly _messageHandler: (event: Electron.Event, method: string, params: unknown, sessionId?: string) => void; private readonly _electronDebugger: Electron.Debugger; + private _targetId: string | undefined; constructor( private readonly view: BrowserView, - private readonly logService: ILogService + readonly logService: ILogService ) { super(); this._electronDebugger = view.webContents.debugger; - // Set up message handler bound to this instance - note the sessionId parameter this._messageHandler = (_event: Electron.Event, method: string, params: unknown, sessionId?: string) => { this.routeCDPEvent(method, params, sessionId); }; @@ -50,137 +70,175 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { /** * Attach to this debugger. - * Creates a dedicated CDP session and returns a connection. - * Dispose the returned connection to detach. + * Attach to a target by its targetId, returning the session. + * Works for both the root page and sub-targets. */ async attach(): Promise { - // Ensure initialized - await this.initialize(); + if (!this._targetId) { + const targetInfo = await this.getTargetInfo(); + this._targetId = targetInfo.targetId; + } + return this.attachToTarget(this._targetId); + } - // Create a dedicated Electron session + async attachToTarget(targetId: string): Promise { + this.ensureAttached(); const result = await this._electronDebugger.sendCommand('Target.attachToTarget', { - targetId: this._realTargetId, + targetId, flatten: true }) as { sessionId: string }; - const sessionId = result.sessionId; - const session = new DebugSession(sessionId, this.view, this._electronDebugger); - this._sessions.set(sessionId, session); - session.onClose(() => this._sessions.deleteAndDispose(sessionId)); + if (!this._sessions.has(result.sessionId)) { + throw new Error(`Failed to attach to target ${targetId}`); + } - return session; + return this._sessions.get(result.sessionId)!; } - /** - * Get CDP target info. - * Initializes the debugger if not already done. - */ async getTargetInfo(): Promise { - // Ensure initialized - await this.initialize(); - - const url = this.view.webContents.getURL() || 'about:blank'; - const title = this.view.webContents.getTitle() || url; - - return { - targetId: this._realTargetId!, - type: 'page', - title, - url, - attached: this._sessions.size > 0, - canAccessOpener: false, - browserContextId: this.view.session.id - }; + this.ensureAttached(); + const result = await this._electronDebugger.sendCommand('Target.getTargetInfo') as { targetInfo: CDPTargetInfo }; + return result.targetInfo; } /** - * Initialize the debugger early to discover the real targetId. + * Send a CDP command. Handles Electron-specific workarounds in a single place. */ - private initialize(): Promise { - if (!this._initializePromise) { - this._initializePromise = (async () => { - this.attachElectronDebugger(); - await this.discoverRealTargetId(); - - if (!this._realTargetId) { - this._initializePromise = undefined; // Allow retry on failure - throw new Error('Could not discover real targetId for this WebContents'); - } - })(); + sendCommand(method: string, params?: unknown, sessionId?: string): Promise { + // This crashes Electron. Don't pass it through. + if (method === 'Emulation.setDeviceMetricsOverride') { + return Promise.resolve({}); } - return this._initializePromise; - } - /** - * Discover the real targetId for this WebContents - */ - private async discoverRealTargetId(): Promise { - try { - const result = await this._electronDebugger.sendCommand('Target.getTargetInfo') as { targetInfo: CDPTargetInfo }; - this._realTargetId = result.targetInfo.targetId; - } catch (error) { - this.logService.error(`[BrowserViewDebugger] Error discovering real targetId:`, error); + this.ensureAttached(); + const resultPromise = this._electronDebugger.sendCommand(method, params, sessionId); + + // Electron overrides dialog behavior — manually dismiss open dialogs. + if (method === 'Page.handleJavaScriptDialog') { + this.view.webContents.emit('-cancel-dialogs'); } + + return resultPromise; } - /** - * Attach to the Electron debugger - */ - private attachElectronDebugger(): void { + private ensureAttached(): void { if (this._electronDebugger.isAttached()) { return; } - this._electronDebugger.attach('1.3'); this._electronDebugger.on('message', this._messageHandler); + this._electronDebugger.attach('1.3'); + + // We use auto-attach to discover descendent targets. + // Regular target discovery doesn't provide ancestor information for workers, + // And we have to filter to avoid including targets from other pages or VS Code internals. + void this._electronDebugger.sendCommand('Target.setAutoAttach', { + autoAttach: true, + flatten: true, + waitForDebuggerOnStart: false + }); + // We still set discoverTargets so we get target info updates. + void this._electronDebugger.sendCommand('Target.setDiscoverTargets', { + discover: true + }); + } + + private detachElectronDebugger(): void { + try { + if (this.view.webContents.isDestroyed() || !this._electronDebugger.isAttached()) { + return; + } + + this._electronDebugger.removeListener('message', this._messageHandler); + this._electronDebugger.detach(); + } catch { + // WebContents may already be destroyed or in an inconsistent state + } } /** - * Route a CDP event to the correct connection by sessionId. - * Fires on the per-connection session for the proxy to handle. + * Route a CDP event from the Electron debugger. */ private routeCDPEvent(method: string, params: unknown, sessionId?: string): void { - if (!sessionId) { - // Events without a sessionId are managed at a higher level, so we can ignore them here. - return; - } - - // Track debugger pause state - if (method === 'Debugger.paused') { + if (method === 'Target.attachedToTarget') { + const p = params as { sessionId: string; targetInfo: CDPTargetInfo; waitingForDebugger: boolean }; + this.registerSession(p.sessionId, p.targetInfo, p.waitingForDebugger, sessionId); + } else if (method === 'Target.detachedFromTarget') { + const p = params as { sessionId: string }; + this._sessions.deleteAndDispose(p.sessionId); + } else if (method === 'Target.targetDestroyed') { + const p = params as { targetId: string }; + this.destroyTarget(p.targetId); + } else if (method === 'Target.targetInfoChanged' && !sessionId) { + const p = params as { targetInfo: CDPTargetInfo }; + if (this._knownTargets.has(p.targetInfo.targetId)) { + this._knownTargets.set(p.targetInfo.targetId, p.targetInfo); + this._onTargetInfoChanged.fire(p.targetInfo); + } + } else if (method === 'Debugger.paused') { this._isPaused = true; } else if (method === 'Debugger.resumed') { this._isPaused = false; } - // Find the session for this sessionId and fire the event - const session = this._sessions.get(sessionId); + const session = sessionId ? this._sessions.get(sessionId) : undefined; if (session) { session.emitEvent({ method, params, sessionId }); } } /** - * Detach from the Electron debugger + * A target was destroyed by the Electron debugger. + * Dispose all sessions belonging to that target before firing the + * lifecycle event so that listeners never observe stale sessions. */ - private detachElectronDebugger(): void { - if (this.view.webContents.isDestroyed() || !this._electronDebugger.isAttached()) { - return; + private destroyTarget(targetId: string): void { + const toDispose: string[] = []; + for (const [sessionId, session] of this._sessions) { + if (session.targetId === targetId) { + toDispose.push(sessionId); + } + } + for (const sessionId of toDispose) { + this._sessions.deleteAndDispose(sessionId); } - this._electronDebugger.removeListener('message', this._messageHandler); - try { - this._electronDebugger.detach(); - } catch (error) { - this.logService.error(`[BrowserViewDebugger] Error detaching from WebContents:`, error); + if (this._knownTargets.delete(targetId)) { + this._onTargetDestroyed.fire(targetId); } } + private registerSession(sessionId: string, targetInfo: CDPTargetInfo, waitingForDebugger: boolean, parentSessionId: string | undefined): DebugSession { + if (!this._knownTargets.has(targetInfo.targetId) && targetInfo.targetId !== this._targetId) { + this._knownTargets.set(targetInfo.targetId, targetInfo); + this._onTargetDiscovered.fire(targetInfo); + } + + if (this._sessions.has(sessionId)) { + return this._sessions.get(sessionId)!; + } + + const session = new DebugSession(parentSessionId, sessionId, targetInfo.targetId, this); + this._sessions.set(sessionId, session); + session.onClose(() => this._sessions.deleteAndDispose(sessionId)); + + this._onSessionCreated.fire({ session, waitingForDebugger }); + + return session; + } + override dispose(): void { this.detachElectronDebugger(); super.dispose(); } } +/** + * A CDP session backed by the Electron debugger. + * + * Pure plumbing — holds a sessionId, emits events, and delegates + * commands to the root {@link BrowserViewDebugger}. + */ class DebugSession extends Disposable implements ICDPConnection { private readonly _onEvent = this._register(new Emitter()); readonly onEvent = this._onEvent.event; @@ -192,28 +250,16 @@ class DebugSession extends Disposable implements ICDPConnection { private _isDisposed = false; constructor( + public readonly parentSessionId: string | undefined, public readonly sessionId: string, - private readonly _view: BrowserView, - private readonly _electronDebugger: Electron.Debugger + public readonly targetId: string, + private readonly _debugger: BrowserViewDebugger, ) { super(); } - async sendCommand(method: string, params?: unknown, _sessionId?: string): Promise { - // This crashes Electron. Don't pass it through. - if (method === 'Emulation.setDeviceMetricsOverride') { - return Promise.resolve({}); - } - - const result = await this._electronDebugger.sendCommand(method, params, this.sessionId); - - // Electron overrides dialog behavior in a way that this command does not auto-dismiss the dialog. - // So we manually emit the (internal) event to dismiss open dialogs when this command is sent. - if (method === 'Page.handleJavaScriptDialog') { - this._view.webContents.emit('-cancel-dialogs'); - } - - return result; + async sendCommand(method: string, params?: unknown): Promise { + return this._debugger.sendCommand(method, params, this.sessionId); } override dispose(): void { @@ -222,9 +268,6 @@ class DebugSession extends Disposable implements ICDPConnection { } this._isDisposed = true; - // Detach from the Electron session (fire and forget) - this._electronDebugger.sendCommand('Target.detachFromTarget', { sessionId: this.sessionId }).catch(() => { }); - this._onClose.fire(); super.dispose(); } diff --git a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts index 5b05488b34cd9..8e8f9e8550a71 100644 --- a/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts +++ b/src/vs/platform/browserView/electron-main/browserViewElementInspector.ts @@ -85,7 +85,7 @@ export class BrowserViewElementInspector extends Disposable { constructor(private readonly browser: BrowserView) { super(); - this._connectionPromise = browser.attach().then( + this._connectionPromise = browser.debugger.attach().then( async conn => { try { // Important: don't use `Runtime.*` commands so we can support inspection during debugging. diff --git a/src/vs/platform/browserView/electron-main/browserViewGroup.ts b/src/vs/platform/browserView/electron-main/browserViewGroup.ts index beed4f6042e9f..901487e3f4ea2 100644 --- a/src/vs/platform/browserView/electron-main/browserViewGroup.ts +++ b/src/vs/platform/browserView/electron-main/browserViewGroup.ts @@ -3,13 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { BrowserView } from './browserView.js'; import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget, CDPRequest, CDPResponse, CDPEvent } from '../common/cdp/types.js'; import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IBrowserViewGroup, IBrowserViewGroupViewEvent } from '../common/browserViewGroup.js'; import { IBrowserViewMainService } from './browserViewMainService.js'; +import { IProductService } from '../../product/common/productService.js'; +import { BrowserSession } from './browserSession.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { BrowserViewCDPTarget } from './browserViewCDPTarget.js'; /** * An isolated group of {@link BrowserView} instances exposed as CDP targets. @@ -24,19 +28,13 @@ import { IBrowserViewMainService } from './browserViewMainService.js'; export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, IBrowserViewGroup { private readonly views = new Map(); - private readonly viewListeners = this._register(new DisposableStore()); + private readonly viewTargets = this._register(new DisposableMap()); /** All context IDs known to this group, including those from views added to it. */ private readonly knownContextIds = new Set(); /** Browser context IDs created by this group via {@link createBrowserContext}. */ private readonly ownedContextIds = new Set(); - private readonly _onTargetCreated = this._register(new Emitter()); - readonly onTargetCreated: Event = this._onTargetCreated.event; - - private readonly _onTargetDestroyed = this._register(new Emitter()); - readonly onTargetDestroyed: Event = this._onTargetDestroyed.event; - private readonly _onDidAddView = this._register(new Emitter()); readonly onDidAddView: Event = this._onDidAddView.event; @@ -46,19 +44,32 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I private readonly _onDidDestroy = this._register(new Emitter()); readonly onDidDestroy: Event = this._onDidDestroy.event; + readonly debugger = this._register(new CDPBrowserProxy(this)); + constructor( readonly id: string, private readonly windowId: number, - @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService + @IBrowserViewMainService private readonly browserViewMainService: IBrowserViewMainService, + @IProductService private readonly productService: IProductService ) { super(); } + get onCDPMessage(): Event { + return this.debugger.onMessage; + } + + sendCDPMessage(msg: CDPRequest): Promise { + return this.debugger.sendMessage(msg); + } + // #region View management /** * Add a {@link BrowserView} to this group. - * Fires {@link onDidAddView} and {@link onTargetCreated}. + * Fires {@link onDidAddView} and registers the view as a CDP target. + * Also subscribes to the view's sub-target events (iframes, workers) + * and bubbles them as group-level target events. * Automatically removes the view when it closes. */ async addView(viewId: string): Promise { @@ -72,16 +83,50 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I this.views.set(view.id, view); this.knownContextIds.add(view.session.id); this._onDidAddView.fire({ viewId: view.id }); - this._onTargetCreated.fire(view); - this.viewListeners.add(Event.once(view.onDidClose)(() => { + // Register the close listener before any async work so we never + // miss a close event that fires during the await. + const closeListener = Event.once(view.onDidClose)(() => { this.removeView(viewId); + }); + + const info = await view.debugger.getTargetInfo(); + + if (this.views.get(viewId) !== view) { + // View was removed while we were awaiting target info + closeListener.dispose(); + return; + } + + // Create a CDP target wrapping the view's debugger transport + const target = new BrowserViewCDPTarget(view, info); + this.viewTargets.set(view.id, target); + + const store = new DisposableStore(); + store.add(closeListener); + target.onClose(() => store.dispose()); + + this.debugger.registerTarget(target); + + // Register sub-targets of the view + for (const targetInfo of view.debugger.knownTargets.values()) { + this.debugger.registerTarget(new BrowserViewCDPTarget(view, targetInfo)); + } + store.add(view.debugger.onTargetDiscovered(targetInfo => { + this.debugger.registerTarget(new BrowserViewCDPTarget(view, targetInfo)); + })); + + // Some sessions won't go through the proxy -- e.g. when auto-attaching to workers. + // So we let the proxy know that the session exists, and it decides whether it cares about it. + store.add(view.debugger.onSessionCreated(({ session, waitingForDebugger }) => { + this.debugger.notifySessionCreated(session, waitingForDebugger); })); } /** * Remove a {@link BrowserView} from this group. - * Fires {@link onDidRemoveView} and {@link onTargetDestroyed} if the view was tracked. + * Disposes the associated {@link BrowserViewCDPTarget}, which cascades + * destruction to sub-targets and sessions via {@link ICDPTarget.onClose}. */ async removeView(viewId: string): Promise { const view = this.views.get(viewId); @@ -91,7 +136,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I this.knownContextIds.delete(view.session.id); } this._onDidRemoveView.fire({ viewId: view.id }); - this._onTargetDestroyed.fire(view); + this.viewTargets.deleteAndDispose(viewId); } } @@ -99,19 +144,49 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I // #region ICDPBrowserTarget implementation + private readonly _onTargetInfoChanged = this._register(new Emitter()); + readonly onTargetInfoChanged = this._onTargetInfoChanged.event; + getVersion(): CDPBrowserVersion { - return this.browserViewMainService.getVersion(); + return { + protocolVersion: '1.3', + product: `${this.productService.nameShort}/${this.productService.version}`, + revision: this.productService.commit || 'unknown', + userAgent: 'Electron', + jsVersion: process.versions.v8 + }; } getWindowForTarget(target: ICDPTarget): { windowId: number; bounds: CDPWindowBounds } { - return this.browserViewMainService.getWindowForTarget(target); + if (!(target instanceof BrowserViewCDPTarget)) { + throw new Error('Can only get window for BrowserView targets'); + } + + const view = target.view.getWebContentsView(); + const viewBounds = view.getBounds(); + return { + windowId: this.windowId, + bounds: { + left: viewBounds.x, + top: viewBounds.y, + width: viewBounds.width, + height: viewBounds.height, + windowState: 'normal' + } + }; } async attach(): Promise { return new CDPBrowserProxy(this); } - async getTargetInfo(): Promise { + /** Browser target sessions are managed by the CDPBrowserProxy, not tracked here. */ + readonly sessions: ReadonlyMap = new Map(); + readonly onSessionCreated = Event.None; + readonly onClose: Event = this._onDidDestroy.event; + notifySessionCreated(): void { } + + get targetInfo(): CDPTargetInfo { return { targetId: this.id, type: 'browser', @@ -122,10 +197,6 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I }; } - getTargets(): IterableIterator { - return this.views.values(); - } - async createTarget(url: string, browserContextId?: string, windowId = this.windowId): Promise { if (browserContextId && !this.knownContextIds.has(browserContextId)) { throw new Error(`Unknown browser context ${browserContextId}`); @@ -134,19 +205,26 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I const target = await this.browserViewMainService.createTarget(url, browserContextId, windowId); if (target instanceof BrowserView) { await this.addView(target.id); + return this.viewTargets.get(target.id)!; } return target; } async activateTarget(target: ICDPTarget): Promise { - return this.browserViewMainService.activateTarget(target); + if (!(target instanceof BrowserViewCDPTarget)) { + throw new Error('Can only activate BrowserView targets'); + } + // TODO@kycutler } async closeTarget(target: ICDPTarget): Promise { - if (target instanceof BrowserView) { - await this.removeView(target.id); + if (!(target instanceof BrowserViewCDPTarget)) { + throw new Error('Can only close BrowserView targets'); } - return this.browserViewMainService.closeTarget(target); + + await this.removeView(target.view.id); + await this.browserViewMainService.destroyBrowserView(target.view.id); + return true; } // Browser context management @@ -160,7 +238,8 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I } async createBrowserContext(): Promise { - const contextId = await this.browserViewMainService.createBrowserContext(); + const browserSession = BrowserSession.getOrCreateEphemeral(generateUuid(), 'cdp-created'); + const contextId = browserSession.id; this.knownContextIds.add(contextId); this.ownedContextIds.add(contextId); return contextId; @@ -171,36 +250,18 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I throw new Error('Can only dispose browser contexts created by this group'); } - // Close views in this group that belong to the context before disposing - for (const view of this.views.values()) { - if (view.session.id === browserContextId) { - await this.removeView(view.id); - } + // Snapshot IDs to avoid mutating the map while iterating + const viewIds = [...this.views.entries()] + .filter(([, view]) => view.session.id === browserContextId) + .map(([id]) => id); + + for (const viewId of viewIds) { + await this.removeView(viewId); + await this.browserViewMainService.destroyBrowserView(viewId); } this.knownContextIds.delete(browserContextId); this.ownedContextIds.delete(browserContextId); - return this.browserViewMainService.disposeBrowserContext(browserContextId); - } - - // #endregion - - // #region CDP endpoint - - private _debugger: CDPBrowserProxy | undefined; - get debugger(): CDPBrowserProxy { - if (!this._debugger) { - this._debugger = this._register(new CDPBrowserProxy(this)); - } - return this._debugger; - } - - async sendCDPMessage(msg: CDPRequest): Promise { - return this.debugger.sendMessage(msg); - } - - get onCDPMessage(): Event { - return this.debugger.onMessage; } // #endregion diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 66d7451982548..e644063ae95c6 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter, Event } from '../../../base/common/event.js'; +import { Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; @@ -16,23 +16,23 @@ import { generateUuid } from '../../../base/common/uuid.js'; import { BrowserViewUri } from '../common/browserViewUri.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { BrowserSession } from './browserSession.js'; -import { IProductService } from '../../product/common/productService.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; -import { CDPBrowserProxy } from '../common/cdp/proxy.js'; import { IntegratedBrowserOpenSource, logBrowserOpen } from '../common/browserViewTelemetry.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { localize } from '../../../nls.js'; import { INativeHostMainService } from '../../native/electron-main/nativeHostMainService.js'; import { ITextEditorOptions } from '../../editor/common/editor.js'; import { htmlAttributeEncodeValue } from '../../../base/common/strings.js'; -import { ICDPTarget, CDPBrowserVersion, CDPWindowBounds, CDPTargetInfo, ICDPConnection, ICDPBrowserTarget } from '../common/cdp/types.js'; export const IBrowserViewMainService = createDecorator('browserViewMainService'); -export interface IBrowserViewMainService extends IBrowserViewService, ICDPBrowserTarget { +export interface IBrowserViewMainService extends IBrowserViewService { readonly _serviceBrand: undefined; tryGetBrowserView(id: string): BrowserView | undefined; + + /** Create a new target, open it in an editor, and return it. */ + createTarget(url: string, browserContextId?: string, windowId?: number): Promise; } export class BrowserViewMainService extends Disposable implements IBrowserViewMainService { @@ -50,18 +50,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private readonly _activeTokens = new Map(); private _keybindings: { [commandId: string]: string } = Object.create(null); - // ICDPBrowserTarget events - private readonly _onTargetCreated = this._register(new Emitter()); - readonly onTargetCreated: Event = this._onTargetCreated.event; - - private readonly _onTargetDestroyed = this._register(new Emitter()); - readonly onTargetDestroyed: Event = this._onTargetDestroyed.event; - constructor( @IEnvironmentMainService private readonly environmentMainService: IEnvironmentMainService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, - @IProductService private readonly productService: IProductService, @ITelemetryService private readonly telemetryService: ITelemetryService, @INativeHostMainService private readonly nativeHostMainService: INativeHostMainService, @IApplicationStorageMainService private readonly applicationStorageMainService: IApplicationStorageMainService @@ -92,57 +84,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this.browserViews.get(id); } - // ICDPBrowserTarget implementation - - getVersion(): CDPBrowserVersion { - return { - protocolVersion: '1.3', - product: `${this.productService.nameShort}/${this.productService.version}`, - revision: this.productService.commit || 'unknown', - userAgent: 'Electron', - jsVersion: process.versions.v8 - }; - } - - getWindowForTarget(target: ICDPTarget): { windowId: number; bounds: CDPWindowBounds } { - if (!(target instanceof BrowserView)) { - throw new Error('Can only get window for targets created by this service'); - } - - const view = target.getWebContentsView(); - const viewBounds = view.getBounds(); - return { - windowId: 1, - bounds: { - left: viewBounds.x, - top: viewBounds.y, - width: viewBounds.width, - height: viewBounds.height, - windowState: 'normal' - } - }; - } - - async attach(): Promise { - return new CDPBrowserProxy(this); - } - - async getTargetInfo(): Promise { - return { - targetId: 'browser', - type: 'browser', - title: this.getVersion().product, - url: '', - attached: true, - canAccessOpener: false - }; - } - - getTargets(): IterableIterator { - return this.browserViews.values(); - } - - async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { + async createTarget(url: string, browserContextId?: string, windowId?: number): Promise { const browserSession = browserContextId ? BrowserSession.get(browserContextId) : undefined; return this.openNew(url, { @@ -153,51 +95,6 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa }); } - async activateTarget(target: ICDPTarget): Promise { - if (!(target instanceof BrowserView)) { - throw new Error('Can only activate targets created by this service'); - } - // TODO@kycutler - } - - async closeTarget(target: ICDPTarget): Promise { - if (!(target instanceof BrowserView)) { - throw new Error('Can only close targets created by this service'); - } - - await this.destroyBrowserView(target.id); - return true; - } - - // Browser context management - - getBrowserContexts(): string[] { - return BrowserSession.getBrowserContextIds(); - } - - async createBrowserContext(): Promise { - const browserSession = BrowserSession.getOrCreateEphemeral(generateUuid(), 'cdp-created'); - return browserSession.id; - } - - async disposeBrowserContext(browserContextId: string): Promise { - if (!browserContextId.startsWith('cdp-created:')) { - throw new Error('Can only dispose browser contexts created via CDP'); - } - - const browserSession = BrowserSession.get(browserContextId); - if (!browserSession) { - throw new Error(`Browser context ${browserContextId} not found`); - } - - // Close all targets in this context - for (const view of this.browserViews.values()) { - if (view.session === browserSession) { - await this.destroyBrowserView(view.id); - } - } - } - /** * Get a browser view or throw if not found */ @@ -305,8 +202,8 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).captureScreenshot(options); } - async focus(id: string): Promise { - return this._getBrowserView(id).focus(); + async focus(id: string, force?: boolean): Promise { + return this._getBrowserView(id).focus(force); } async findInPage(id: string, text: string, options?: IBrowserViewFindInPageOptions): Promise { @@ -404,9 +301,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa ); this.browserViews.set(id, view); - this._onTargetCreated.fire(view); Event.once(view.onDidClose)(() => { - this._onTargetDestroyed.fire(view); this.browserViews.deleteAndDispose(id); }); @@ -426,7 +321,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa editorOptions: ITextEditorOptions; source: IntegratedBrowserOpenSource; } - ): Promise { + ): Promise { const targetId = generateUuid(); const view = this.createBrowserView(targetId, session || BrowserSession.getOrCreateEphemeral(targetId)); diff --git a/src/vs/platform/networkFilter/common/networkFilterService.ts b/src/vs/platform/networkFilter/common/networkFilterService.ts index ff833bc7d38a8..9d3a540ee7de4 100644 --- a/src/vs/platform/networkFilter/common/networkFilterService.ts +++ b/src/vs/platform/networkFilter/common/networkFilterService.ts @@ -10,6 +10,8 @@ import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { AgentSandboxSettingId } from '../../sandbox/common/settings.js'; +import { ITerminalSandboxService } from '../../sandbox/common/terminalSandboxService.js'; import { extractDomainFromUri, isDomainAllowed } from './domainMatcher.js'; import { AgentNetworkDomainSettingId } from './settings.js'; @@ -19,8 +21,9 @@ export const IAgentNetworkFilterService = createDecorator(100); @@ -61,9 +65,11 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo constructor( @IConfigurationService private readonly configurationService: IConfigurationService, + @ITerminalSandboxService private readonly terminalSandboxService: ITerminalSandboxService, ) { super(); this.readConfiguration(); + void this.updateTerminalSandboxEnabled(); this._register(this.configurationService.onDidChangeConfiguration(e => { if ( @@ -73,19 +79,36 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo ) { this.readConfiguration(); this.onDidChangeEmitter.fire(); + } else if ( + e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxEnabled) || + e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) + ) { + void this.updateTerminalSandboxEnabled(); } })); } private readConfiguration(): void { - this.enabled = this.configurationService.getValue(AgentNetworkDomainSettingId.NetworkFilter) ?? false; + const networkFilterEnabled = this.configurationService.getValue(AgentNetworkDomainSettingId.NetworkFilter) ?? false; + + this.enabled = networkFilterEnabled || this.terminalSandboxEnabled; this.allowedPatterns = this.configurationService.getValue(AgentNetworkDomainSettingId.AllowedNetworkDomains) ?? []; this.deniedPatterns = this.configurationService.getValue(AgentNetworkDomainSettingId.DeniedNetworkDomains) ?? []; this.domainCache.clear(); } + private async updateTerminalSandboxEnabled(): Promise { + const enabled = await this.terminalSandboxService.isEnabled(); + if (this.terminalSandboxEnabled === enabled) { + return; + } + this.terminalSandboxEnabled = enabled; + this.readConfiguration(); + this.onDidChangeEmitter.fire(); + } + isUriAllowed(uri: URI): boolean { - // When the network filter is disabled, allow all requests. + // When domain filtering is inactive, allow all requests. if (!this.enabled) { return true; } diff --git a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts index 6f92696aa7a3f..8b8aa23e8d4a9 100644 --- a/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts +++ b/src/vs/platform/networkFilter/test/common/networkFilterService.test.ts @@ -11,15 +11,21 @@ import { ConfigurationTarget } from '../../../configuration/common/configuration import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; import { AgentNetworkFilterService } from '../../common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../common/settings.js'; +import { AgentSandboxSettingId } from '../../../sandbox/common/settings.js'; +import { ITerminalSandboxService, NullTerminalSandboxService } from '../../../sandbox/common/terminalSandboxService.js'; suite('AgentNetworkFilterService', () => { let disposables: DisposableStore; let configService: TestConfigurationService; + let terminalSandboxEnabled: boolean; + let terminalSandboxService: ITerminalSandboxService; setup(() => { disposables = new DisposableStore(); configService = new TestConfigurationService(); + terminalSandboxEnabled = false; + terminalSandboxService = Object.assign(new NullTerminalSandboxService(), { isEnabled: async () => terminalSandboxEnabled }); configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, true); configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, []); configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, []); @@ -31,9 +37,10 @@ suite('AgentNetworkFilterService', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createService(): AgentNetworkFilterService { - const service = new AgentNetworkFilterService(configService); + async function createService(): Promise { + const service = new AgentNetworkFilterService(configService, terminalSandboxService); disposables.add(service); + await Promise.resolve(); return service; } @@ -46,65 +53,76 @@ suite('AgentNetworkFilterService', () => { }); } - test('allows all domains when filter is disabled', () => { + test('allows all domains when filter is disabled', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); - const service = createService(); + const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), true); }); - test('denies all domains when both lists are empty', () => { - const service = createService(); + test('network filter disabled with sandbox enabled activates filtering', async () => { + configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); + terminalSandboxEnabled = true; + configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); + + const service = await createService(); + + assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false); + }); + + test('denies all domains when both lists are empty', async () => { + const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), false); assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), false); }); - test('blocks denied domains', () => { + test('blocks denied domains', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['evil.com']); - const service = createService(); + const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://evil.com')), false); assert.strictEqual(service.isUriAllowed(URI.parse('https://good.com')), true); }); - test('restricts to allowed domains', () => { + test('restricts to allowed domains', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); - const service = createService(); + const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false); }); - test('denied takes precedence over allowed', () => { + test('denied takes precedence over allowed', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['*.com']); configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['evil.com']); - const service = createService(); + const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://safe.com')), true); assert.strictEqual(service.isUriAllowed(URI.parse('https://evil.com')), false); }); suite('isUriAllowed', () => { - test('allows file URIs', () => { - const service = createService(); + test('allows file URIs', async () => { + const service = await createService(); configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['*']); assert.strictEqual(service.isUriAllowed(URI.file('/tmp/test.txt')), true); }); - test('allows URIs without authority', () => { - const service = createService(); + test('allows URIs without authority', async () => { + const service = await createService(); configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['*']); assert.strictEqual(service.isUriAllowed(URI.from({ scheme: 'untitled', path: 'Untitled-1' })), true); }); - test('checks domain for http/https URIs', () => { + test('checks domain for http/https URIs', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); - const service = createService(); + const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com/page')), true); assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com/page')), false); }); }); test('fires onDidChange when configuration changes', async () => { - const service = createService(); + const service = await createService(); let fired = false; disposables.add(service.onDidChange(() => { fired = true; })); @@ -116,7 +134,7 @@ suite('AgentNetworkFilterService', () => { test('updates filtering after configuration change', async () => { configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); - const service = createService(); + const service = await createService(); assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['example.com']); @@ -124,4 +142,22 @@ suite('AgentNetworkFilterService', () => { assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), false); }); + + test('terminal sandbox enablement change fires onDidChange and updates filtering', async () => { + configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false); + configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']); + const service = await createService(); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true); + + let fired = false; + disposables.add(service.onDidChange(() => { fired = true; })); + + terminalSandboxEnabled = true; + fireConfigChange(AgentSandboxSettingId.AgentSandboxEnabled); + await Promise.resolve(); + + assert.strictEqual(fired, true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true); + assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false); + }); }); diff --git a/src/vs/platform/sandbox/common/settings.ts b/src/vs/platform/sandbox/common/settings.ts new file mode 100644 index 0000000000000..4f6496b9af108 --- /dev/null +++ b/src/vs/platform/sandbox/common/settings.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Setting IDs for agent sandboxing. + */ +export const enum AgentSandboxSettingId { + AgentSandboxEnabled = 'chat.agent.sandbox.enabled', + DeprecatedAgentSandboxEnabled = 'chat.agent.sandbox', +} + +export const enum AgentSandboxEnabledValue { + Off = 'off', + On = 'on', +} diff --git a/src/vs/platform/sandbox/common/terminalSandboxService.ts b/src/vs/platform/sandbox/common/terminalSandboxService.ts new file mode 100644 index 0000000000000..f790fbdb1fdb0 --- /dev/null +++ b/src/vs/platform/sandbox/common/terminalSandboxService.ts @@ -0,0 +1,127 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Event } from '../../../base/common/event.js'; +import { URI } from '../../../base/common/uri.js'; +import { OperatingSystem, OS } from '../../../base/common/platform.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { TerminalCapability } from '../../terminal/common/capabilities/capabilities.js'; + +export const ITerminalSandboxService = createDecorator('terminalSandboxService'); + +export interface ITerminalSandboxResolvedNetworkDomains { + allowedDomains: string[]; + deniedDomains: string[]; +} + +export const enum TerminalSandboxPrerequisiteCheck { + Config = 'config', + Dependencies = 'dependencies', +} + +export interface ITerminalSandboxPrerequisiteCheckResult { + enabled: boolean; + sandboxConfigPath: string | undefined; + failedCheck: TerminalSandboxPrerequisiteCheck | undefined; + missingDependencies?: string[]; +} + +export interface ITerminalSandboxWrapResult { + command: string; + isSandboxWrapped: boolean; + blockedDomains?: string[]; + deniedDomains?: string[]; + requiresUnsandboxConfirmation?: boolean; +} + +/** + * Abstraction over terminal operations needed by the install flow. + * Provided by the browser-layer caller so the common-layer service + * does not import browser types directly. + */ +export interface ISandboxDependencyInstallTerminal { + sendText(text: string, addNewLine?: boolean): Promise; + focus(): void; + capabilities: { + get(id: TerminalCapability.CommandDetection): { onCommandFinished: Event<{ exitCode: number | undefined }> } | undefined; + onDidAddCapability: Event<{ id: TerminalCapability }>; + }; + onDidInputData: Event; + onDisposed: Event; +} + +export interface ISandboxDependencyInstallOptions { + /** + * Creates or obtains a terminal for running the install command. + */ + createTerminal(): Promise; + /** + * Focuses the terminal for password entry. + */ + focusTerminal(terminal: ISandboxDependencyInstallTerminal): Promise; +} + +export interface ISandboxDependencyInstallResult { + exitCode: number | undefined; +} + +export interface ITerminalSandboxService { + readonly _serviceBrand: undefined; + isEnabled(): Promise; + getOS(): Promise; + checkForSandboxingPrereqs(forceRefresh?: boolean): Promise; + wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult; + getSandboxConfigPath(forceRefresh?: boolean): Promise; + getTempDir(): URI | undefined; + setNeedsForceUpdateConfigFile(): void; + getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains; + getMissingSandboxDependencies(): Promise; + installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise; +} + +export class NullTerminalSandboxService implements ITerminalSandboxService { + readonly _serviceBrand: undefined; + + async isEnabled(): Promise { + return false; + } + + async getOS(): Promise { + return OS; + } + + async checkForSandboxingPrereqs(): Promise { + return { enabled: false, sandboxConfigPath: undefined, failedCheck: undefined }; + } + + wrapCommand(command: string): ITerminalSandboxWrapResult { + return { command, isSandboxWrapped: false }; + } + + async getSandboxConfigPath(): Promise { + return undefined; + } + + getTempDir(): URI | undefined { + return undefined; + } + + setNeedsForceUpdateConfigFile(): void { + // No-op. + } + + getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains { + return { allowedDomains: [], deniedDomains: [] }; + } + + async getMissingSandboxDependencies(): Promise { + return []; + } + + async installMissingSandboxDependencies(): Promise { + return { exitCode: undefined }; + } +} diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 13ea6570c2e62..d9d5c9b08a128 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -140,6 +140,7 @@ The account widget is rendered in the **right side of the titlebar** as a custom - Registered in `contrib/accountMenu/browser/account.contribution.ts` - Uses the `Menus.TitleBarRightLayout` menu - Shows the signed-in GitHub profile image when available, and falls back to the existing account codicon when it is not +- Gives the GitHub profile image a subtle 1px circular border using the titlebar command center border tokens so the avatar stays legible against nearby chrome in both active and inactive window states - Opens a combined account and Copilot status hover panel with sign-in/sign-out, settings, and update actions --- @@ -657,6 +658,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-17 | Added a subtle 1px titlebar-token border around the sessions account widget's GitHub profile image, including the inactive-window variant, and documented the avatar chrome in the layout spec. | | 2026-04-16 | Softened the experimental sessions shell gradient by reducing the accent tint mix strength across the shared default, light-theme, and dark-theme variants so the primary color reads more subtly behind the workbench chrome. | | 2026-04-16 | Updated the layout visual representation to show the editor part in the top-right row and mark it as hidden by default. | | 2026-04-16 | Fixed the sessions workbench so modal editor opens no longer hide an already visible main editor part, and documented that the main editor stays hidden by default but can be revealed by explicit non-modal editor flows. | diff --git a/src/vs/sessions/common/agentHostDiffs.ts b/src/vs/sessions/common/agentHostDiffs.ts index 2194a1f5e57ad..5b5949aeaee7d 100644 --- a/src/vs/sessions/common/agentHostDiffs.ts +++ b/src/vs/sessions/common/agentHostDiffs.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isDefined } from '../../base/common/types.js'; import { URI } from '../../base/common/uri.js'; import { SessionStatus as ProtocolSessionStatus } from '../../platform/agentHost/common/state/protocol/state.js'; +import { ISessionFileDiff } from '../../platform/agentHost/common/state/sessionState.js'; import { SessionStatus } from '../services/sessions/common/session.js'; /** @@ -30,35 +32,44 @@ export interface IFileChange { readonly deletions: number; } -type RawDiff = { readonly uri: string; readonly added?: number; readonly removed?: number }; - /** * Converts agent host diffs to the chat session file change format. * * @param mapUri Optional URI mapper applied after parsing. The remote agent * host provider uses this to rewrite `file:` URIs into agent-host URIs. */ -export function diffsToChanges(diffs: readonly RawDiff[], mapUri?: (uri: URI) => URI): IFileChange[] { - return diffs.map(d => ({ - modifiedUri: mapUri ? mapUri(URI.parse(d.uri)) : URI.parse(d.uri), - insertions: d.added ?? 0, - deletions: d.removed ?? 0, - })); +export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): IFileChange[] { + return diffs.map(d => { + const uri = d.after?.uri || d.before?.uri; + if (!uri) { + return undefined; + } + return { + modifiedUri: mapUri ? mapUri(URI.parse(uri)) : URI.parse(uri), + insertions: d.diff?.added ?? 0, + deletions: d.diff?.removed ?? 0, + }; + }).filter(isDefined); } /** * Returns `true` when the current file changes already - * match the incoming raw diffs, avoiding unnecessary observable updates. + * match the incoming diffs, avoiding unnecessary observable updates. */ -export function diffsEqual(current: readonly IFileChange[], raw: readonly RawDiff[], mapUri?: (uri: URI) => URI): boolean { - if (current.length !== raw.length) { +export function diffsEqual(current: readonly IFileChange[], diffs: readonly ISessionFileDiff[], mapUri?: (uri: URI) => URI): boolean { + if (current.length !== diffs.length) { return false; } for (let i = 0; i < current.length; i++) { const c = current[i]; - const r = raw[i]; - const rawUri = mapUri ? mapUri(URI.parse(r.uri)) : URI.parse(r.uri); - if (c.modifiedUri.toString() !== rawUri.toString() || c.insertions !== (r.added ?? 0) || c.deletions !== (r.removed ?? 0)) { + const d = diffs[i]; + const uri = d.after?.uri || d.before?.uri; + if (!uri) { + continue; + } + const parsed = URI.parse(uri); + const diffUri = mapUri ? mapUri(parsed) : parsed; + if (c.modifiedUri.toString() !== diffUri.toString() || c.insertions !== (d.diff?.added ?? 0) || c.deletions !== (d.diff?.removed ?? 0)) { return false; } } diff --git a/src/vs/sessions/common/sessionsTelemetry.ts b/src/vs/sessions/common/sessionsTelemetry.ts index 4762d6c2ed14f..75d449d5fd9f5 100644 --- a/src/vs/sessions/common/sessionsTelemetry.ts +++ b/src/vs/sessions/common/sessionsTelemetry.ts @@ -111,3 +111,64 @@ type ChangesViewReviewCommentAddedClassification = { export function logChangesViewReviewCommentAdded(telemetryService: ITelemetryService, data: { hasExistingFeedback: boolean; hasSuggestion: boolean; isFromPRReview: boolean }): void { telemetryService.publicLog2('vscodeAgents.changesView/reviewCommentAdded', data); } + +// --- Tunnel agent host connect --- + +export type TunnelConnectErrorCategory = 'relayConnectionFailed' | 'auth' | 'network' | 'other'; +export type TunnelConnectFailureReason = 'hostOffline' | 'maxAttemptsReached'; + +type TunnelConnectAttemptEvent = { + isReconnect: boolean; + attempt: number; + durationMs: number; + success: boolean; + errorCategory: string; +}; + +type TunnelConnectAttemptClassification = { + owner: 'osortega'; + comment: 'Tracks individual agent-host tunnel connect attempts for performance and reliability.'; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether this attempt was part of a reconnect cycle (true) or an initial connect (false).' }; + attempt: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Attempt number within the current connect session (1-based).' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Duration of this individual attempt in milliseconds.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether this individual attempt succeeded.' }; + errorCategory: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Category of error when the attempt failed (relayConnectionFailed, auth, network, other); empty on success.' }; +}; + +export function logTunnelConnectAttempt(telemetryService: ITelemetryService, data: { isReconnect: boolean; attempt: number; durationMs: number; success: boolean; errorCategory?: TunnelConnectErrorCategory }): void { + telemetryService.publicLog2('vscodeAgents.tunnelConnect/attempt', { + isReconnect: data.isReconnect, + attempt: data.attempt, + durationMs: data.durationMs, + success: data.success, + errorCategory: data.errorCategory ?? '', + }); +} + +type TunnelConnectResolvedEvent = { + isReconnect: boolean; + totalAttempts: number; + totalDurationMs: number; + success: boolean; + failureReason: string; +}; + +type TunnelConnectResolvedClassification = { + owner: 'osortega'; + comment: 'Tracks overall agent-host tunnel connect session outcomes for reliability.'; + isReconnect: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the resolved session was a reconnect cycle (true) or an initial connect (false).' }; + totalAttempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total number of attempts made before resolution.' }; + totalDurationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total elapsed time from session start to resolution in milliseconds.' }; + success: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the connect session ultimately succeeded.' }; + failureReason: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Reason the session terminated without connecting (hostOffline, maxAttemptsReached); empty on success.' }; +}; + +export function logTunnelConnectResolved(telemetryService: ITelemetryService, data: { isReconnect: boolean; totalAttempts: number; totalDurationMs: number; success: boolean; failureReason?: TunnelConnectFailureReason }): void { + telemetryService.publicLog2('vscodeAgents.tunnelConnect/resolved', { + isReconnect: data.isReconnect, + totalAttempts: data.totalAttempts, + totalDurationMs: data.totalDurationMs, + success: data.success, + failureReason: data.failureReason ?? '', + }); +} diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index 41225bf20b8fd..3dbb70d8a5285 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -51,7 +51,9 @@ flex: 0 0 auto; width: 16px; height: 16px; + border: 1px solid var(--vscode-commandCenter-border, transparent); border-radius: 50%; + box-sizing: border-box; object-fit: cover; } @@ -59,6 +61,10 @@ display: block; } +.monaco-workbench .part.titlebar.inactive .sessions-account-titlebar-widget-avatar { + border-color: var(--vscode-commandCenter-inactiveBorder, var(--vscode-commandCenter-border, transparent)); +} + .agent-sessions-workbench .sessions-account-titlebar-widget-label { display: none; } diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 1073c8c24222a..c28b8d2f79442 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -543,11 +543,15 @@ export class ChangesViewPane extends ViewPane { } const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); - dom.setVisibility(hasGitRepository, this.filesHeaderNode!); const { files } = topLevelStats.read(reader); const hasEntries = files > 0; + // Show the files header whenever the session is git-backed (so users + // can switch version modes) or there are session-provided entries to + // count (for non-git sessions like the local agent host). + dom.setVisibility(hasGitRepository || hasEntries, this.filesHeaderNode!); + dom.setVisibility(hasEntries, this.listContainer!); dom.setVisibility(!hasEntries, this.welcomeContainer!); @@ -1196,6 +1200,7 @@ class VersionsPickerAction extends Action2 { id: MenuId.ChatEditingSessionChangesFileHeaderToolbar, group: 'navigation', order: 9, + when: ActiveSessionContextKeys.HasGitRepository, }], }); } diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index b0c16e0e88ef1..491f2c282ff97 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -142,10 +142,7 @@ export class ViewAllSessionChangesAction extends Action2 { title: localize2('chatEditing.viewAllSessionChanges', 'View All Changes'), icon: Codicon.diffMultiple, f1: false, - precondition: ContextKeyExpr.and( - ContextKeyExpr.equals('sessions.hasGitRepository', true), - ChatContextKeys.hasAgentSessionChanges, - ), + precondition: ChatContextKeys.hasAgentSessionChanges, menu: [ { id: MenuId.ChatEditingSessionChangesToolbar, diff --git a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts index bc3919926b9ab..5114a455fa932 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewModel.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewModel.ts @@ -320,15 +320,21 @@ export class ChangesViewModel extends Disposable { return derivedOpts({ equalsFn: arrayEqualsC() }, reader => { + const versionMode = this.versionModeObs.read(reader); + + // BranchChanges reads from the session provider's `changes` + // observable directly (e.g. agent-host-tracked diffs), so it + // works even for sessions without a git repository. + if (versionMode === ChangesVersionMode.BranchChanges) { + return activeSessionChangesObs.read(reader); + } + const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader); if (!hasGitRepository && !isWeb) { return []; } - const versionMode = this.versionModeObs.read(reader); - if (versionMode === ChangesVersionMode.BranchChanges) { - return activeSessionChangesObs.read(reader); - } else if (versionMode === ChangesVersionMode.UncommittedChanges) { + if (versionMode === ChangesVersionMode.UncommittedChanges) { return this._activeSessionUncommittedChangesPromiseObs.read(reader).read(reader) ?? []; } else if (versionMode === ChangesVersionMode.AllChanges) { return this._activeSessionAllChangesPromiseObs.read(reader).read(reader) ?? []; diff --git a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts index af919f0afb171..12573f7b24048 100644 --- a/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/localAgentHost/browser/localAgentHostSessionsProvider.ts @@ -59,17 +59,6 @@ function buildMutableConfigSchema(config: Record): Record): Record()); + constructor( @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, - @IAgentHostService agentHostService: IAgentHostService, - @ITerminalProfileService terminalProfileService: ITerminalProfileService, - @IQuickInputService quickInputService: IQuickInputService, - @IInstantiationService instantiationService: IInstantiationService, - @IAgentHostTerminalService agentHostTerminalService: IAgentHostTerminalService, + @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { - super( - agentHostService, - terminalProfileService, - quickInputService, - instantiationService, - agentHostTerminalService - ); - - - // React to connection changes - this._register(this._remoteAgentHostService.onDidChangeConnections(() => { - this._reconcile(); - })); + super(); - // The base-class constructor already called _reconcile(), but at that - // point _remoteAgentHostService was not yet assigned (guard returned - // early). Re-reconcile now to pick up any existing connections. - this._reconcile(); + this._register(this._remoteAgentHostService.onDidChangeConnections(() => this._reconcileRemote())); + this._reconcileRemote(); } - protected override _collectEntries(): IAgentHostEntry[] { - const entries: IAgentHostEntry[] = []; - // Guard: _remoteAgentHostService may not be assigned yet when the - // base-class constructor calls _reconcile() before super() returns. - if (!this._remoteAgentHostService) { - return isWeb ? entries : super._collectEntries(); - } - // Remote connections + private _reconcileRemote(): void { + const connectedAddresses = new Set(); + for (const info of this._remoteAgentHostService.connections) { if (info.status !== RemoteAgentHostConnectionStatus.Connected) { continue; @@ -60,20 +40,28 @@ export class RemoteAgentHostTerminalContribution extends AgentHostTerminalContri if (!connection) { continue; } - - entries.push({ - name: info.name || info.address, - address: info.address, - getConnection: () => this._instantiationService.createInstance( - LoggingAgentConnection, - connection, - `agenthost.${connection.clientId}`, - localize('agentHostTerminal.channelRemote', "Agent Host Terminal ({0})", info.address), - ), - }); + connectedAddresses.add(info.address); + if (!this._remoteEntries.has(info.address)) { + this._remoteEntries.set(info.address, this._agentHostTerminalService.registerEntry({ + name: info.name || info.address, + address: info.address, + getConnection: () => this._instantiationService.createInstance( + LoggingAgentConnection, + connection, + `agenthost.${connection.clientId}`, + localize('agentHostTerminal.channelRemote', "Agent Host Terminal ({0})", info.address), + ), + })); + } } - return isWeb ? entries : [...entries, ...super._collectEntries()]; + // Remove entries for disconnected hosts + for (const address of this._remoteEntries.keys()) { + if (!connectedAddresses.has(address)) { + this._remoteEntries.deleteAndDispose(address); + } + } } } -registerWorkbenchContribution2(AgentHostTerminalContribution.ID, RemoteAgentHostTerminalContribution, WorkbenchPhase.AfterRestored); + +registerWorkbenchContribution2('workbench.contrib.remoteAgentHostTerminal', RemoteAgentHostTerminalContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index 34a3195f9c371..4f8e3fd342f4f 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; import { isWeb } from '../../../../base/common/platform.js'; +import { mainWindow } from '../../../../base/browser/window.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'; @@ -12,14 +13,27 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IAuthenticationService } from '../../../../workbench/services/authentication/common/authentication.js'; +import { logTunnelConnectAttempt, logTunnelConnectResolved, TunnelConnectErrorCategory, TunnelConnectFailureReason } from '../../../common/sessionsTelemetry.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; +/** Initial auto-reconnect delay after an unexpected tunnel disconnect. */ +const RECONNECT_INITIAL_DELAY = 1000; +/** Maximum auto-reconnect backoff delay. */ +const RECONNECT_MAX_DELAY = 30_000; +/** + * Consecutive failures before pausing auto-reconnect. We resume immediately + * on a network-online event or when the tab becomes visible, so this is + * mostly a guard against a permanently dead tunnel. + */ +const RECONNECT_MAX_ATTEMPTS = 10; + export class TunnelAgentHostContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.contrib.tunnelAgentHostContribution'; @@ -29,6 +43,24 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc private readonly _pendingConnects = new Map>(); private _lastStatusCheck = 0; + /** Previous connection status per address — used to detect Connected→Disconnected transitions. */ + private readonly _previousStatuses = new Map(); + /** Pending auto-reconnect timer per address. */ + private readonly _reconnectTimeouts = new Map>(); + /** Consecutive failed auto-reconnect attempts per address. */ + private readonly _reconnectAttempts = new Map(); + /** Addresses whose auto-reconnect loop has paused after too many failures. */ + private readonly _reconnectPaused = new Set(); + /** Timestamp of the last wake-triggered resume, to rate-limit rapid tab toggles. */ + private _lastResumeAt = 0; + + /** + * Per-address connect sessions for telemetry. A session starts at the + * first attempt of a connect cycle (initial or reconnect) and ends on + * terminal resolution (connected, host-offline, max-attempts). + */ + private readonly _connectSessions = new Map(); + constructor( @ITunnelAgentHostService private readonly _tunnelService: ITunnelAgentHostService, @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, @@ -38,6 +70,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc @INotificationService private readonly _notificationService: INotificationService, @ILogService private readonly _logService: ILogService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -46,6 +79,7 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // Update connection statuses when connections change this._register(this._remoteAgentHostService.onDidChangeConnections(() => { + this._handleConnectionChanges(); this._updateConnectionStatuses(); this._wireConnections(); })); @@ -53,6 +87,8 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc // Reconcile providers when the tunnel cache changes this._register(this._tunnelService.onDidChangeTunnels(() => { this._reconcileProviders(); + // Stop any reconnect loops for tunnels that no longer exist + this._pruneReconnectState(); })); // Re-run discovery when a GitHub session becomes available @@ -64,6 +100,32 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } })); + // Wake-triggered retry: when the browser regains connectivity or + // the tab becomes visible again, immediately attempt to reconnect + // any disconnected tunnels. This covers laptop-sleep / Wi-Fi-drop + // scenarios where we may have paused the reconnect loop. + if (isWeb) { + const onWake = () => this._resumeReconnects('wake'); + mainWindow.addEventListener('online', onWake); + this._register(toDisposable(() => mainWindow.removeEventListener('online', onWake))); + + const onVisibilityChange = () => { + if (mainWindow.document.visibilityState === 'visible') { + this._resumeReconnects('visible'); + } + }; + mainWindow.document.addEventListener('visibilitychange', onVisibilityChange); + this._register(toDisposable(() => mainWindow.document.removeEventListener('visibilitychange', onVisibilityChange))); + } + + // Cancel any pending reconnect timers on disposal. + this._register(toDisposable(() => { + for (const timer of this._reconnectTimeouts.values()) { + clearTimeout(timer); + } + this._reconnectTimeouts.clear(); + })); + // Silently check status of cached tunnels on startup this._silentStatusCheck(); } @@ -169,6 +231,12 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc return Promise.resolve(); } + // A new attempt is starting — cancel any scheduled reconnect timer; + // success/failure of this attempt will drive the next decision. + this._cancelReconnect(address); + + const { attemptNumber, attemptStart, session, isReconnect } = this._beginConnectAttempt(address); + const promise = (async () => { // Show a progress notification after a short delay so quick // connects don't flash a notification. @@ -192,6 +260,24 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc hostConnectionCount: 0, }; await this._tunnelService.connect(tunnelInfo, cached.authProvider); + this._finishConnectAttempt(address, { success: true, attemptNumber, attemptStart, session, isReconnect }); + } catch (err) { + this._logService.warn(`[TunnelAgentHost] Connect to ${cached.name} failed:`, err); + this._finishConnectAttempt(address, { success: false, attemptNumber, attemptStart, session, isReconnect, error: err }); + // Clear the pending-connect entry BEFORE deciding what to do + // next; otherwise `_scheduleReconnect`'s in-flight guard + // (`_pendingConnects.has(address)`) would silently bail and + // we'd never re-arm the timer, leaving the tunnel stuck. + this._pendingConnects.delete(address); + + const hostOnline = await this._probeHostOnline(cached.tunnelId); + if (hostOnline === false) { + this._pauseReconnect(address, 'hostOffline'); + } else { + this._logService.info(`[TunnelAgentHost] Scheduling reconnect for ${address}`); + this._scheduleReconnect(address); + } + throw err; } finally { clearTimeout(timer); handle?.close(); @@ -200,10 +286,322 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc } })(); + // Swallow the promise rejection here so unhandled rejection noise + // doesn't bubble up for the background reconnect path; callers that + // await `_connectTunnel` directly will still see it via their own `await`. + promise.catch(() => { /* handled via _scheduleReconnect */ }); + this._pendingConnects.set(address, promise); return promise; } + /** + * Detect tunnel connections that transitioned from Connected to + * Disconnected and schedule an auto-reconnect. + * + * Important: we only trigger on a Connected → Disconnected transition + * where the connection entry is still present. If the entry has been + * removed from the service (e.g. the user clicked "Remove Remote"), + * we do NOT schedule a reconnect — that would override their intent. + */ + private _handleConnectionChanges(): void { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + return; + } + + const cachedAddresses = new Set( + this._tunnelService.getCachedTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`) + ); + const currentStatuses = new Map(); + for (const conn of this._remoteAgentHostService.connections) { + currentStatuses.set(conn.address, conn.status); + } + + for (const address of cachedAddresses) { + const previous = this._previousStatuses.get(address); + const current = currentStatuses.get(address); + + // Only schedule a reconnect on an explicit Connected→Disconnected + // transition. If the address is absent from the connection list, + // the user (or another code path) removed it — honour that. + const wasConnected = previous === RemoteAgentHostConnectionStatus.Connected; + const isExplicitlyDisconnected = current === RemoteAgentHostConnectionStatus.Disconnected; + + if (wasConnected && isExplicitlyDisconnected && !this._pendingConnects.has(address)) { + this._logService.info(`[TunnelAgentHost] Connection lost for ${address}, scheduling reconnect`); + if (!this._connectSessions.has(address)) { + this._connectSessions.set(address, { startedAt: Date.now(), attempts: 0, isReconnect: true }); + } + this._scheduleReconnect(address, /*immediate*/ true); + } + + // Only track previous status while the entry is present so a + // future re-registration starts from a clean slate. If the + // entry disappeared (e.g. user-initiated removal), also cancel + // any already-scheduled reconnect and clear its backoff state + // so the removal is honoured even if a timer was already armed. + if (current !== undefined) { + this._previousStatuses.set(address, current); + } else { + this._previousStatuses.delete(address); + this._resetReconnectState(address); + } + } + + // Drop previous-status entries for addresses no longer cached. + for (const address of [...this._previousStatuses.keys()]) { + if (!cachedAddresses.has(address)) { + this._previousStatuses.delete(address); + } + } + } + + private _scheduleReconnect(address: string, immediate = false): void { + // Respect enablement and tunnel-still-cached. + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + return; + } + const tunnelId = address.slice(TUNNEL_ADDRESS_PREFIX.length); + const cached = this._tunnelService.getCachedTunnels().find(t => t.tunnelId === tunnelId); + if (!cached) { + return; + } + + // Already connected or a connect is in flight — nothing to do. + if (this._pendingConnects.has(address)) { + return; + } + const live = this._remoteAgentHostService.connections.find(c => c.address === address); + if (live && live.status === RemoteAgentHostConnectionStatus.Connected) { + this._clearReconnectBackoff(address); + return; + } + + // Cancel any existing timer — we're rescheduling. + this._cancelReconnect(address); + + const attempt = this._reconnectAttempts.get(address) ?? 0; + + if (attempt >= RECONNECT_MAX_ATTEMPTS) { + this._pauseReconnect(address, 'maxAttemptsReached'); + return; + } + + const delay = immediate + ? 0 + : Math.min(RECONNECT_INITIAL_DELAY * Math.pow(2, attempt), RECONNECT_MAX_DELAY); + + this._logService.info( + `[TunnelAgentHost] Scheduling reconnect for ${address} in ${delay}ms (attempt ${attempt + 1}/${RECONNECT_MAX_ATTEMPTS})` + ); + + const timer = setTimeout(() => { + this._reconnectTimeouts.delete(address); + + // A manual (or other) connect may have started or completed while + // we were waiting. Re-check before counting this as a new attempt, + // otherwise `_connectTunnel` would just return the in-flight promise + // and we'd inflate the backoff counter without really trying again. + if (this._pendingConnects.has(address)) { + return; + } + const live = this._remoteAgentHostService.connections.find(c => c.address === address); + if (live && live.status === RemoteAgentHostConnectionStatus.Connected) { + this._clearReconnectBackoff(address); + return; + } + + this._reconnectAttempts.set(address, attempt + 1); + this._connectTunnel(address).catch(() => { /* _connectTunnel already re-schedules on failure */ }); + }, delay); + this._reconnectTimeouts.set(address, timer); + } + + /** + * Best-effort probe of whether the host backing `tunnelId` is online + * (has any host connections). Returns `undefined` if we couldn't + * determine — caller should treat as "retry normally" in that case. + */ + private async _probeHostOnline(tunnelId: string): Promise { + try { + const tunnels = await this._tunnelService.listTunnels({ silent: true }); + if (!tunnels) { + return undefined; + } + const info = tunnels.find(t => t.tunnelId === tunnelId); + if (!info) { + return false; + } + return info.hostConnectionCount > 0; + } catch { + return undefined; + } + } + + private _cancelReconnect(address: string): void { + const timer = this._reconnectTimeouts.get(address); + if (timer !== undefined) { + clearTimeout(timer); + this._reconnectTimeouts.delete(address); + } + } + + /** Clear retry-backoff and pause state for an address. */ + private _clearReconnectBackoff(address: string): void { + this._reconnectAttempts.delete(address); + this._reconnectPaused.delete(address); + } + + /** Drop all reconnect + telemetry state for an address (e.g. on removal). */ + private _resetReconnectState(address: string): void { + this._cancelReconnect(address); + this._clearReconnectBackoff(address); + this._connectSessions.delete(address); + } + + /** + * Stop auto-reconnecting for an address until a wake/online/visibility + * event resumes us, and close out any active telemetry session. + */ + private _pauseReconnect(address: string, reason: TunnelConnectFailureReason): void { + this._cancelReconnect(address); + this._reconnectAttempts.delete(address); + this._reconnectPaused.add(address); + this._logService.info( + `[TunnelAgentHost] Pausing auto-reconnect for ${address} (${reason}); ` + + `will resume on network-online, tab-visible, or next status check.` + ); + const session = this._connectSessions.get(address); + if (session) { + logTunnelConnectResolved(this._telemetryService, { + isReconnect: session.isReconnect, + totalAttempts: session.attempts, + totalDurationMs: Date.now() - session.startedAt, + success: false, + failureReason: reason, + }); + this._connectSessions.delete(address); + } + } + + /** + * Begin (or continue) a connect telemetry session for `address` and + * return the bookkeeping needed to later finish the attempt. A session + * already exists if `_handleConnectionChanges` marked this as a + * reconnect cycle; otherwise this starts a fresh initial-connect session. + */ + private _beginConnectAttempt(address: string): { session: { startedAt: number; attempts: number; isReconnect: boolean }; attemptNumber: number; attemptStart: number; isReconnect: boolean } { + let session = this._connectSessions.get(address); + if (!session) { + session = { startedAt: Date.now(), attempts: 0, isReconnect: false }; + this._connectSessions.set(address, session); + } + session.attempts++; + return { session, attemptNumber: session.attempts, attemptStart: Date.now(), isReconnect: session.isReconnect }; + } + + /** + * Finalize the telemetry for a single connect attempt. On success, also + * clears backoff state and closes the session; on failure, only the + * per-attempt event is emitted (the caller decides whether to retry). + */ + private _finishConnectAttempt(address: string, args: { + success: boolean; + attemptNumber: number; + attemptStart: number; + session: { startedAt: number; attempts: number; isReconnect: boolean }; + isReconnect: boolean; + error?: unknown; + }): void { + const { success, attemptNumber, attemptStart, session, isReconnect, error } = args; + const durationMs = Date.now() - attemptStart; + if (success) { + this._clearReconnectBackoff(address); + logTunnelConnectAttempt(this._telemetryService, { isReconnect, attempt: attemptNumber, durationMs, success: true }); + logTunnelConnectResolved(this._telemetryService, { isReconnect, totalAttempts: attemptNumber, totalDurationMs: Date.now() - session.startedAt, success: true }); + this._connectSessions.delete(address); + } else { + logTunnelConnectAttempt(this._telemetryService, { isReconnect, attempt: attemptNumber, durationMs, success: false, errorCategory: this._categorizeError(error) }); + } + } + + private _categorizeError(err: unknown): TunnelConnectErrorCategory { + const message = err instanceof Error ? err.message : String(err); + if (/WebSocket relay connection failed/i.test(message)) { + return 'relayConnectionFailed'; + } + if (/authenticat|token|unauthor/i.test(message)) { + return 'auth'; + } + if (/network|fetch|offline/i.test(message)) { + return 'network'; + } + return 'other'; + } + + /** + * Invoked on `online` / `visibilitychange→visible`. Kicks off an + * immediate attempt for any disconnected cached tunnel. + * + * Rate-limited: at most one resume per RESUME_RATE_LIMIT_MS so that + * rapid tab toggling can't hammer a permanently broken endpoint with + * an unbounded number of attempt bursts. Resumes the normal backoff + * sequence (by clearing the pause flag) rather than zeroing the + * attempt counter. + */ + private _resumeReconnects(trigger: 'wake' | 'visible'): void { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + return; + } + + const RESUME_RATE_LIMIT_MS = 10_000; + const now = Date.now(); + if (now - this._lastResumeAt < RESUME_RATE_LIMIT_MS) { + return; + } + this._lastResumeAt = now; + + const cached = this._tunnelService.getCachedTunnels(); + for (const tunnel of cached) { + const address = `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`; + if (this._pendingConnects.has(address)) { + continue; + } + const live = this._remoteAgentHostService.connections.find(c => c.address === address); + if (live && live.status === RemoteAgentHostConnectionStatus.Connected) { + continue; + } + + this._logService.info(`[TunnelAgentHost] Resuming reconnect for ${address} (trigger: ${trigger})`); + // If we were paused (exhausted the backoff budget), give a fresh + // budget since the wake event is itself evidence the environment + // has changed. Otherwise keep the current attempt counter so an + // in-progress backoff isn't short-circuited. + if (this._reconnectPaused.has(address)) { + this._clearReconnectBackoff(address); + } + this._scheduleReconnect(address, /*immediate*/ true); + } + } + + /** Drop reconnect state for addresses whose tunnel is no longer cached. */ + private _pruneReconnectState(): void { + const cachedAddresses = new Set( + this._tunnelService.getCachedTunnels().map(t => `${TUNNEL_ADDRESS_PREFIX}${t.tunnelId}`) + ); + const tracked = new Set([ + ...this._reconnectTimeouts.keys(), + ...this._reconnectAttempts.keys(), + ...this._reconnectPaused, + ...this._connectSessions.keys(), + ]); + for (const address of tracked) { + if (!cachedAddresses.has(address)) { + this._resetReconnectState(address); + } + } + } + // -- Silent status check -- private async _silentStatusCheck(): Promise { diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index 81c2055ca199a..e0d453627eab0 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -12,8 +12,6 @@ import { Action2, registerAction2 } from '../../../../platform/actions/common/ac import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; -import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; @@ -39,7 +37,6 @@ export interface ICustomizationItemConfig { readonly id: string; readonly label: string; readonly icon: ThemeIcon; - readonly section: AICustomizationManagementSection; readonly promptType?: PromptsType; readonly isMcp?: boolean; readonly isPlugins?: boolean; @@ -50,42 +47,36 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ id: 'sessions.customization.agents', label: localize('agents', "Agents"), icon: agentIcon, - section: AICustomizationManagementSection.Agents, promptType: PromptsType.agent, }, { id: 'sessions.customization.skills', label: localize('skills', "Skills"), icon: skillIcon, - section: AICustomizationManagementSection.Skills, promptType: PromptsType.skill, }, { id: 'sessions.customization.instructions', label: localize('instructions', "Instructions"), icon: instructionsIcon, - section: AICustomizationManagementSection.Instructions, promptType: PromptsType.instructions, }, { id: 'sessions.customization.hooks', label: localize('hooks', "Hooks"), icon: hookIcon, - section: AICustomizationManagementSection.Hooks, promptType: PromptsType.hook, }, { id: 'sessions.customization.mcpServers', label: localize('mcpServers', "MCP Servers"), icon: mcpServerIcon, - section: AICustomizationManagementSection.McpServers, isMcp: true, }, { id: 'sessions.customization.plugins', label: localize('plugins', "Plugins"), icon: pluginIcon, - section: AICustomizationManagementSection.Plugins, isPlugins: true, }, ]; @@ -241,10 +232,7 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo async run(accessor: ServicesAccessor): Promise { const editorService = accessor.get(IEditorService); const input = AICustomizationManagementEditorInput.getOrCreate(); - const editor = await editorService.openEditor(input, { pinned: true }); - if (editor instanceof AICustomizationManagementEditor) { - editor.selectSectionById(config.section); - } + await editorService.openEditor(input, { pinned: true }); } })); } diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 2b74a3f4fecd5..11dfd1e6446e1 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -5,46 +5,59 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { autorun, derived } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; import { ITerminalInstance, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { TerminalCapability } from '../../../../platform/terminal/common/capabilities/capabilities.js'; import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; import { Menus } from '../../../browser/menus.js'; +import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { CopilotCLISessionType, ISession } from '../../../services/sessions/common/session.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logSessionsInteraction } from '../../../common/sessionsTelemetry.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; +import { ITerminalProfileService, TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { AGENT_HOST_SCHEME } from '../../../../platform/agentHost/common/agentHostUri.js'; const SessionsTerminalViewVisibleContext = new RawContextKey('sessionsTerminalViewVisible', false); +interface ISessionTerminalInfo { + /** The cwd to use for terminal matching/creation. For agent host sessions this is the unwrapped file URI. */ + readonly cwd: URI; + /** When set, the terminal should be created on the agent host rather than locally. */ + readonly agentHostCwd?: URI; +} + /** - * Returns the cwd URI for the given session: worktree or repository path for + * Returns terminal info for the given session: worktree or repository path for * background sessions only. Returns `undefined` for non-background sessions * (Cloud, Local, etc.) which have no local worktree, or when no path is available. */ -function getSessionCwd(session: ISession | undefined): URI | undefined { +function getSessionTerminalInfo(session: ISession | undefined): ISessionTerminalInfo | undefined { if (session?.sessionType !== CopilotCLISessionType.id) { return undefined; } const repo = session.workspace.get()?.repositories[0]; const cwd = repo?.workingDirectory ?? repo?.uri; - if (cwd?.scheme === AGENT_HOST_SCHEME) { + if (!cwd) { return undefined; } - return cwd; + if (cwd.scheme === AGENT_HOST_SCHEME) { + return { cwd: fromAgentHostUri(cwd), agentHostCwd: cwd }; + } + return { cwd }; } /** @@ -61,14 +74,46 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @ITerminalService private readonly _terminalService: ITerminalService, + @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, @ILogService private readonly _logService: ILogService, @IPathService private readonly _pathService: IPathService, + @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @IViewsService viewsService: IViewsService, @IContextKeyService contextKeyService: IContextKeyService, ) { super(); + const profileOverride = derived(reader => { + const profiles = this._agentHostTerminalService.profiles.read(reader); + const session = this._sessionsManagementService.activeSession.read(reader); + const address = this._getSessionAgentHostAddress(session); + if (!address) { + return; + } + + return profiles.find(p => p.address === address) ?? this._agentHostTerminalService.getProfileForConnection(address); + }); + + this._register(autorun(reader => { + const profile = profileOverride.read(reader); + if (profile) { + reader.store.add(this._terminalProfileService.overrideDefaultProfile( + profile.extensionIdentifier, profile.profileId, + )); + } + })); + + // Keep the default cwd in sync with the active session's working directory + // so that "New Terminal" uses it automatically. + // This is a little hacky but I don't see any better approach. + this._register(autorun(reader => { + const session = this._sessionsManagementService.activeSession.read(reader); + const info = getSessionTerminalInfo(session); + this._agentHostTerminalService.setDefaultCwd(info?.cwd); + })); + // Track whether the terminal view is visible so the titlebar toggle // button shows the correct checked state. const terminalViewVisible = SessionsTerminalViewVisibleContext.bindTo(contextKeyService); @@ -118,14 +163,18 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben * Ensures a terminal exists for the given cwd by scanning all terminal * instances for a matching initial cwd. If none is found, creates a new * one. Sets it as active and optionally focuses it. + * + * When {@link session} is provided and the session is backed by an agent + * host, the terminal is created on the agent host instead of locally. */ - async ensureTerminal(cwd: URI, focus: boolean): Promise { + async ensureTerminal(cwd: URI, focus: boolean, session?: ISession): Promise { const key = cwd.fsPath.toLowerCase(); let existing = await this._findTerminalsForKey(key); if (existing.length === 0) { try { - const createdInstance = this._getAvailableTerminal(await this._terminalService.createTerminal({ config: { cwd } }), `activate created terminal for ${cwd.fsPath}`); + const instance = await this._createTerminalForSession(cwd, session); + const createdInstance = this._getAvailableTerminal(instance, `activate created terminal for ${cwd.fsPath}`); if (!createdInstance) { return []; } @@ -145,21 +194,50 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben return existing; } + /** + * Creates a terminal for the given cwd. If the session is backed by an + * agent host, creates an agent host terminal; otherwise creates a local one. + */ + private async _createTerminalForSession(cwd: URI, session: ISession | undefined): Promise { + const address = session && this._getSessionAgentHostAddress(session); + if (address) { + const instance = await this._agentHostTerminalService.createTerminalForEntry(address, { cwd }); + if (instance) { + return instance; + } + } + return this._terminalService.createTerminal({ config: { cwd } }); + } + + /** + * Returns the agent host address for the given session's provider, + * or `undefined` if the session is not backed by an agent host. + */ + private _getSessionAgentHostAddress(session: ISession | undefined): string | undefined { + if (!session) { + return undefined; + } + const provider = this._sessionsProvidersService.getProvider(session.providerId); + if (!provider || !isAgentHostProvider(provider)) { + return undefined; + } + return provider.remoteAddress ?? '__local__'; + } + private async _onActiveSessionChanged(session: ISession | undefined): Promise { if (!session) { return; } - const sessionCwd = getSessionCwd(session); - - const targetPath = sessionCwd ?? await this._pathService.userHome(); + const info = getSessionTerminalInfo(session); + const targetPath = info?.cwd ?? await this._pathService.userHome(); const targetKey = targetPath.fsPath.toLowerCase(); if (this._activeKey === targetKey) { return; } this._activeKey = targetKey; - const instances = await this.ensureTerminal(targetPath, false); + const instances = await this.ensureTerminal(targetPath, false, info?.agentHostCwd ? session : undefined); // If the active key changed while we were awaiting, a newer call has // taken over — skip the visibility update to avoid flicker. @@ -340,8 +418,9 @@ class OpenSessionInTerminalAction extends Action2 { const pathService = _accessor.get(IPathService); const activeSession = sessionsManagementService.activeSession.get(); - const cwd = getSessionCwd(activeSession) ?? await pathService.userHome(); - await contribution.ensureTerminal(cwd, true); + const info = getSessionTerminalInfo(activeSession); + const cwd = info?.cwd ?? await pathService.userHome(); + await contribution.ensureTerminal(cwd, true, info?.agentHostCwd ? activeSession : undefined); viewsService.openView(TERMINAL_VIEW_ID); } } diff --git a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts index 2ad1eda7cdabd..b188df4dee4b6 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -4,10 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, Disposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { Emitter } from '../../../../../base/common/event.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { IAgentHostTerminalService } from '../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; +import { ITerminalProfileService } from '../../../../../workbench/contrib/terminal/common/terminal.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -272,6 +275,21 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(IPathService, new TestPathService(HOME_DIR)); + instantiationService.stub(IAgentHostTerminalService, new class extends mock() { + override readonly profiles = constObservable([]); + override getProfileForConnection() { return undefined; } + override setDefaultCwd(): void { /* noop */ } + override async createTerminalForEntry() { return undefined; } + }); + + instantiationService.stub(ITerminalProfileService, new class extends mock() { + override overrideDefaultProfile() { return Disposable.None; } + }); + + instantiationService.stub(ISessionsProvidersService, new class extends mock() { + override getProvider() { return undefined; } + }); + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); instantiationService.stub(IViewsService, new class extends mock() { @@ -702,14 +720,14 @@ suite('SessionsTerminalContribution', () => { // --- Remote agent host sessions --- - test('falls back to home directory for a background session with a remote agent host repository', async () => { + test('uses the unwrapped repository path for a background session with a remote agent host repository', async () => { const remoteRepoUri = toAgentHostUri(URI.file('/Users/user/repo'), 'my-server'); const session = makeAgentSession({ repository: remoteRepoUri, providerType: AgentSessionProviders.Background }); activeSessionObs.set(session, undefined); await tick(); - assert.strictEqual(createdTerminals.length, 1, 'should create a terminal at the home directory'); - assert.strictEqual(createdTerminals[0].cwd.fsPath, HOME_DIR.fsPath); + assert.strictEqual(createdTerminals.length, 1, 'should create a terminal at the unwrapped repository path'); + assert.strictEqual(createdTerminals[0].cwd.fsPath, URI.file('/Users/user/repo').fsPath); }); }); diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 27cf2b70ef985..a169d81c62616 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -1119,7 +1119,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio controllerData.onDidChangeChatSessionItemStateEmitter.fire(item); } - async $provideChatSessionInputState(controllerHandle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise { + async $provideChatSessionInputState(controllerHandle: number, sessionResourceComponents: UriComponents | undefined, token: CancellationToken): Promise { const controllerData = this._chatSessionItemControllers.get(controllerHandle); if (!controllerData) { this._logService.warn(`No controller found for handle ${controllerHandle}`); @@ -1130,14 +1130,13 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio if (!handler) { return undefined; } - - const sessionResource = URI.revive(sessionResourceComponents); - const inputState = await handler(isUntitledChatSession(sessionResource) ? undefined : sessionResource, { previousInputState: undefined }, token); + const sessionResource = sessionResourceComponents ? URI.revive(sessionResourceComponents) : undefined; + const inputState = await handler(!sessionResource || isUntitledChatSession(sessionResource) ? undefined : sessionResource, { previousInputState: undefined }, token); if (!inputState) { return undefined; } - if (inputState instanceof ChatSessionInputStateImpl) { + if (inputState instanceof ChatSessionInputStateImpl && sessionResource) { if (isUntitledChatSession(sessionResource)) { inputState.untitledSessionResource = sessionResource; } else { diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index f78d0c8f2e25c..849efe3775c19 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -203,7 +203,7 @@ export interface IBrowserViewModel extends IDisposable { reload(hard?: boolean): Promise; toggleDevTools(): Promise; captureScreenshot(options?: IBrowserViewCaptureScreenshotOptions): Promise; - focus(): Promise; + focus(force?: boolean): Promise; findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise; stopFindInPage(keepSelection?: boolean): Promise; getSelectedText(): Promise; @@ -492,8 +492,8 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return result; } - async focus(): Promise { - return this.browserViewService.focus(this.id); + async focus(force?: boolean): Promise { + return this.browserViewService.focus(this.id, force); } async findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise { 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 a9ee98e9f3c19..44a84be163943 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -177,6 +177,16 @@ async function findExistingPagesByHost( ) { results.push(editor); } + // Check for subdomain matches + if ( + editorUrl?.host && parsed.host && + ( + editorUrl.host.endsWith('.' + parsed.host) || + parsed.host.endsWith('.' + editorUrl.host) + ) + ) { + results.push(editor); + } } return results; } 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 604cde33453bb..73fa5961b0632 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -11,7 +11,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; -import { autorun, derived, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -43,7 +43,7 @@ import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, To import { getAgentHostIcon } from '../agentSessions.js'; import { AgentHostEditingSession } from './agentHostEditingSession.js'; import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, makeAhpTerminalToolSessionId, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, type IToolCallFileEdit } from './stateToProgressAdapter.js'; // ============================================================================= // AgentHostSessionHandler - renderer-side handler for a single agent host @@ -180,7 +180,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { private readonly _onDidStartServerRequest = this._register(new Emitter<{ prompt: string }>()); readonly onDidStartServerRequest = this._onDidStartServerRequest.event; - interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; + readonly interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; readonly forkSession: IChatSession['forkSession']; constructor( @@ -189,6 +189,7 @@ class AgentHostChatSession extends Disposable implements IChatSession { private readonly _forkSession: ((request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise), initialProgress: IChatProgress[] | undefined, onDispose: () => void, + interruptActiveResponse: () => boolean, @ILogService private readonly _logService: ILogService, ) { super(); @@ -202,11 +203,10 @@ class AgentHostChatSession extends Disposable implements IChatSession { this._register(toDisposable(() => this._onWillDispose.fire())); this._register(toDisposable(onDispose)); - // Provide interrupt callback when reconnecting to an active turn or - // when this is a brand-new session (no history yet). - this.interruptActiveResponseCallback = (hasActiveTurn || history.length === 0) ? async () => { - return true; - } : undefined; + // Always provide an interrupt callback so the chat UI's stop button + // can cancel a remote turn at any time. The callback resolves the + // current active turn at call time and dispatches SessionTurnCancelled. + this.interruptActiveResponseCallback = async () => interruptActiveResponse(); this.forkSession = this._forkSession; } @@ -241,8 +241,10 @@ class AgentHostChatSession extends Disposable implements IChatSession { */ startServerRequest(prompt: string): void { this._logService.info('[AgentHost] Server-initiated request started'); - this.progressObs.set([], undefined); - this.isCompleteObs.set(false, undefined); + transaction(tx => { + this.progressObs.set([], tx); + this.isCompleteObs.set(false, tx); + }); this._onDidStartServerRequest.fire({ prompt }); } } @@ -455,7 +457,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const sessionState = this._getSessionState(resolvedSession.toString()); if (sessionState) { const modelId = this._toLanguageModelId(sessionResource, sessionState.summary.model?.id); - history.push(...turnsToHistory(resolvedSession, sessionState.turns, this._config.agentId, modelId)); + history.push(...turnsToHistory(resolvedSession, sessionState.turns, this._config.agentId, this._config.connectionAuthority, modelId)); // Enrich history with inner tool calls from subagent // child sessions. Subscribes to each child session so @@ -489,7 +491,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC parts: [], participant: this._config.agentId, }); - initialProgress = activeTurnToProgress(resolvedSession, sessionState.activeTurn); + initialProgress = activeTurnToProgress(resolvedSession, sessionState.activeTurn, this._config.connectionAuthority); this._logService.info(`[AgentHost] Reconnecting to active turn ${activeTurnId} for session ${resolvedSession.toString()}`); } } @@ -524,6 +526,27 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._releaseSessionSubscription(resolvedSession.toString()); } }, + () => { + const backend = resolvedSession ?? this._sessionToBackend.get(sessionResource); + if (!backend) { + // Nothing to cancel. Treat as a successful noop so ChatService + // does not install a phantom pending request. + return true; + } + const sessionKey = backend.toString(); + const turnId = this._getSessionState(sessionKey)?.activeTurn?.id; + if (!turnId) { + // No active turn (likely a race with completion). Noop-success. + return true; + } + this._logService.info(`[AgentHost] Cancellation requested for ${sessionKey}, dispatching turnCancelled`); + this._config.connection.dispatch({ + type: ActionType.SessionTurnCancelled, + session: sessionKey, + turnId, + }); + return true; + }, ); this._activeSessions.set(sessionResource, session); @@ -1121,18 +1144,16 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const existingState = existing.state.get(); if (existingState.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { existing.didExecuteTool(undefined); - const confirmInvocation = toolCallStateToInvocation(tc, undefined, ctx.backendSession); + const confirmInvocation = toolCallStateToInvocation(tc, undefined, ctx.backendSession, this._config.connectionAuthority); ctx.activeToolInvocations.set(toolCallId, confirmInvocation); ctx.progress([confirmInvocation]); this._awaitToolConfirmation(confirmInvocation, toolCallId, ctx.backendSession, ctx.turnId, ctx.cancellationToken); existing = confirmInvocation; } } else if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.PendingResultConfirmation) { - existing.invocationMessage = typeof tc.invocationMessage === 'string' - ? tc.invocationMessage - : new MarkdownString(tc.invocationMessage.markdown); + existing.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, this._config.connectionAuthority); this._reviveTerminalIfNeeded(existing, tc, ctx.backendSession); - updateRunningToolSpecificData(existing, tc); + updateRunningToolSpecificData(existing, tc, this._config.connectionAuthority); } // Finalize terminal-state tools @@ -1141,7 +1162,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // Running was skipped due to throttling and terminal content // only appears at Completed time. this._reviveTerminalIfNeeded(existing, tc, ctx.backendSession); - fileEdits = finalizeToolInvocation(existing, tc, ctx.backendSession); + fileEdits = finalizeToolInvocation(existing, tc, ctx.backendSession, this._config.connectionAuthority); } return { invocation: existing, fileEdits }; @@ -1533,7 +1554,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // string gets merged into the edit part in chatModel.ts // which breaks rendering because the thinking content // part does not deal with this. - ctx.progress([{ kind: 'markdownContent', content: new MarkdownString(delta, { supportHtml: true }) }]); + ctx.progress([{ kind: 'markdownContent', content: rawMarkdownToString(delta, this._config.connectionAuthority, { supportHtml: true }) }]); } break; } @@ -1561,7 +1582,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC break; } - existing = toolCallStateToInvocation(tc, undefined, ctx.backendSession); + existing = toolCallStateToInvocation(tc, undefined, ctx.backendSession, this._config.connectionAuthority); ctx.activeToolInvocations.set(tc.toolCallId, existing); ctx.progress([existing]); @@ -1708,7 +1729,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC /* resolveId */ undefined, /* data */ undefined, /* isUsed */ undefined, - /* message */ new MarkdownString(inputReq.message), + /* message */ rawMarkdownToString(inputReq.message, this._config.connectionAuthority), ); progress([carousel]); @@ -1836,7 +1857,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { const completedTc = tc as ICompletedToolCall; const fileEditParts = completedToolCallToEditParts(completedTc); - const serialized = completedToolCallToSerialized(completedTc, toolCallId, URI.parse(childSessionUri)); + const serialized = completedToolCallToSerialized(completedTc, toolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); if (fileEditParts.length > 0) { serialized.presentation = ToolInvocationPresentation.Hidden; } @@ -1887,7 +1908,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC let existing = activeChildToolInvocations.get(tc.toolCallId); if (!existing) { - existing = toolCallStateToInvocation(tc, parentToolCallId, URI.parse(childSessionUri)); + existing = toolCallStateToInvocation(tc, parentToolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); activeChildToolInvocations.set(tc.toolCallId, existing); emitProgress([existing]); @@ -1898,17 +1919,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const existingState = existing.state.get(); if (existingState.type !== IChatToolInvocation.StateKind.WaitingForConfirmation) { existing.didExecuteTool(undefined); - const confirmInvocation = toolCallStateToInvocation(tc, parentToolCallId, URI.parse(childSessionUri)); + const confirmInvocation = toolCallStateToInvocation(tc, parentToolCallId, URI.parse(childSessionUri), this._config.connectionAuthority); activeChildToolInvocations.set(tc.toolCallId, confirmInvocation); emitProgress([confirmInvocation]); this._awaitToolConfirmation(confirmInvocation, tc.toolCallId, URI.parse(childSessionUri), turnId, childCts.token); } } else if (tc.status === ToolCallStatus.Running) { - updateRunningToolSpecificData(existing, tc); + updateRunningToolSpecificData(existing, tc, this._config.connectionAuthority); } if (existing && (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(existing)) { - finalizeToolInvocation(existing, tc, URI.parse(childSessionUri)); + finalizeToolInvocation(existing, tc, URI.parse(childSessionUri), this._config.connectionAuthority); } } } @@ -2001,18 +2022,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const throttler = new Throttler(); reconnectDisposables.add(throttler); - // Set up the interrupt callback so the user can actually cancel the - // remote turn. This dispatches session/turnCancelled to the server. - chatSession.interruptActiveResponseCallback = async () => { - this._logService.info(`[AgentHost] Reconnect cancellation requested for ${sessionKey}, dispatching turnCancelled`); - this._config.connection.dispatch({ - type: ActionType.SessionTurnCancelled, - session: sessionKey, - turnId, - }); - return true; - }; - // Wire up awaitConfirmation for tool calls that were already pending // confirmation at snapshot time so the user can approve/deny them. // Also start observing any subagent tools that were already running. diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index f1f14e024a5eb..55f59189cb300 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -10,7 +10,7 @@ import { hasKey } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; -import { SessionStatus, type ISessionFileDiff, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ISessionFileDiff, SessionStatus, type ISessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ChatSessionStatus, IChatSessionFileChange2, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; import { getAgentHostIcon } from '../agentSessions.js'; @@ -170,7 +170,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS this._onDidChangeChatSessionItems.fire({ addedOrUpdated: this._items }); } - private _makeItemFromSummary(rawId: string, summary: ISessionSummary, diffs: ISessionFileDiff[] | undefined): IChatSessionItem { + private _makeItemFromSummary(rawId: string, summary: ISessionSummary, diffs: readonly ISessionFileDiff[] | undefined): IChatSessionItem { const workingDir = typeof summary.workingDirectory === 'string' ? URI.parse(summary.workingDirectory) : summary.workingDirectory; return this._makeItem(rawId, { title: summary.title, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts index f3f7748df8ab6..f178541476d8d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts @@ -3,113 +3,38 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { URI } from '../../../../../../base/common/uri.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; -import { IAgentConnection, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IQuickInputService, IQuickPickItem } from '../../../../../../platform/quickinput/common/quickInput.js'; import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; import { LoggingAgentConnection } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.js'; import { IAgentHostTerminalService } from '../../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; -import { ITerminalProfileProvider, ITerminalProfileService } from '../../../../../../workbench/contrib/terminal/common/terminal.js'; - -const AGENT_HOST_PROFILE_EXT_ID = 'vscode.agent-host-terminal'; - -export interface IAgentHostEntry { - /** Display name for the profile */ - readonly name: string; - /** Address or identifier for the host */ - readonly address: string; - /** Getter for the connection (may be lazily resolved) */ - readonly getConnection: () => IAgentConnection | undefined; -} /** - * Registers terminal profiles for connected agent hosts, allowing users to - * open terminals on remote (or local) agent host processes directly from the - * terminal dropdown. + * Registers local agent host terminal entries with + * {@link IAgentHostTerminalService} so they appear in the terminal dropdown. */ export class AgentHostTerminalContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.agentHostTerminal'; - private readonly _registrations = this._register(new DisposableMap()); - private readonly _usedHosts = new Set(); + private readonly _localEntry = this._register(new MutableDisposable()); constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, - @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, - @IQuickInputService private readonly _quickInputService: IQuickInputService, - @IInstantiationService protected readonly _instantiationService: IInstantiationService, @IAgentHostTerminalService private readonly _agentHostTerminalService: IAgentHostTerminalService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); - // React to local agent host lifecycle this._register(this._agentHostService.onAgentHostStart(() => this._reconcile())); this._register(this._agentHostService.onAgentHostExit(() => this._reconcile())); - - // Initial reconciliation this._reconcile(); } - protected _reconcile(): void { - const entries = this._collectEntries(); - - // Determine which profiles to show - const desiredProfiles = new Map(); - - if (entries.length === 0) { - // No hosts connected — no profiles - } else if (entries.length === 1) { - // Single host — always show a named profile - const entry = entries[0]; - desiredProfiles.set(entry.address, entry); - } else { - // Multiple hosts, some active — show named profiles for active ones - let displaying = 0; - for (const address of this._usedHosts) { - const entry = entries.find(e => e.address === address); - if (entry) { - displaying++; - desiredProfiles.set(entry.address, entry); - } - } - - if (displaying === entries.length - 1) { - const missing = entries.find(e => !this._usedHosts.has(e.address)); - if (missing) { - desiredProfiles.set(missing.address, missing); - } - } else if (displaying < entries.length) { - // Multiple hosts, none active — show a generic quickpick profile - desiredProfiles.set('__quickpick__', { - name: localize('agentHostTerminal.pick', "Agent Host\u2026"), - address: '__quickpick__', - getConnection: () => undefined, - }); - } - } - - // Diff and update profile registrations - for (const [key, entry] of desiredProfiles) { - if (!this._registrations.has(key)) { - this._registerProfile(key, entry, entries); - } - } - for (const key of this._registrations.keys()) { - if (!desiredProfiles.has(key)) { - this._registrations.deleteAndDispose(key); - } - } - } - - protected _collectEntries(): IAgentHostEntry[] { - const entries: IAgentHostEntry[] = []; - - // Local agent host - try { - entries.push({ + private _reconcile(): void { + if (!this._localEntry.value) { + this._localEntry.value = this._agentHostTerminalService.registerEntry({ name: localize('agentHostTerminal.local', "Local"), address: '__local__', getConnection: () => this._instantiationService.createInstance( @@ -119,72 +44,6 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe localize('agentHostTerminal.channelLocal', "Agent Host Terminal (Local)"), ), }); - } catch { - // Local agent host may not be available } - - return entries; - } - - private _registerProfile(key: string, entry: IAgentHostEntry, allEntries: IAgentHostEntry[]): void { - const provider: ITerminalProfileProvider = { - createContributedTerminalProfile: async (options) => { - let connection: IAgentConnection | undefined; - let displayName = entry.name; - - if (key === '__quickpick__') { - // Show quickpick to let user choose a host - const picks: (IQuickPickItem & { address: string; hostName: string })[] = allEntries.map(e => ({ - label: localize('agentHostTerminal.profileName', "Agent Host ({0})", e.name), - address: e.address, - hostName: e.name, - })); - const pick = await this._quickInputService.pick(picks, { - placeHolder: localize('agentHostTerminal.pickHost', "Select an agent host to open a terminal on"), - }); - if (!pick) { - return; - } - this._usedHosts.add(pick.address); - this._reconcile(); - displayName = pick.hostName; - connection = allEntries.find(e => e.address === pick.address)?.getConnection(); - } else { - connection = entry.getConnection(); - } - - if (!connection) { - return; - } - - await this._agentHostTerminalService.createTerminal(connection, { - name: localize('agentHostTerminal.profileName', "Agent Host ({0})", displayName), - cwd: options.cwd ? (typeof options.cwd === 'string' ? URI.file(options.cwd) : options.cwd) : undefined, - location: options.location, - }); - }, - }; - - const title = key === '__quickpick__' - ? localize('agentHostTerminal.pick', "Agent Host\u2026") - : localize('agentHostTerminal.profileName', "Agent Host ({0})", entry.name); - - const store = new DisposableStore(); - store.add(this._terminalProfileService.registerTerminalProfileProvider( - AGENT_HOST_PROFILE_EXT_ID, - key, - provider, - )); - - // Register the profile metadata in-memory so it appears in the - // contribution list without writing to user configuration. - store.add(this._terminalProfileService.registerInternalContributedProfile({ - extensionIdentifier: AGENT_HOST_PROFILE_EXT_ID, - id: key, - title, - icon: 'remote', - })); - - this._registrations.set(key, store); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index baa70814c3438..5fc1cbe6c97fa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -4,14 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn, FileEditKind, ToolResultContentType, type IToolResultContent } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; -import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { StringOrMarkdown, type IFileEdit } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { type IToolConfirmationMessages, type IToolData, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; -import { isEqual } from '../../../../../../base/common/resources.js'; +import { basename, isEqual } from '../../../../../../base/common/resources.js'; import { hasKey } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; @@ -85,7 +88,7 @@ export function getTerminalContentUri(content: IToolResultContent[] | undefined) /** * Converts completed turns from the protocol state into session history items. */ -export function turnsToHistory(backendSession: URI, turns: readonly ITurn[], participantId: string, modelId?: string): IChatSessionHistoryItem[] { +export function turnsToHistory(backendSession: URI, turns: readonly ITurn[], participantId: string, connectionAuthority: string | undefined, modelId?: string): IChatSessionHistoryItem[] { const history: IChatSessionHistoryItem[] = []; for (const turn of turns) { // Request @@ -98,13 +101,13 @@ export function turnsToHistory(backendSession: URI, turns: readonly ITurn[], par switch (rp.kind) { case ResponsePartKind.Markdown: if (rp.content) { - parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content, { supportHtml: true }) }); + parts.push({ kind: 'markdownContent', content: rawMarkdownToString(rp.content, connectionAuthority, { supportHtml: true }) }); } break; case ResponsePartKind.ToolCall: { const tc = rp.toolCall as ICompletedToolCall; const fileEditParts = completedToolCallToEditParts(tc); - const serialized = completedToolCallToSerialized(tc, undefined, backendSession); + const serialized = completedToolCallToSerialized(tc, undefined, backendSession, connectionAuthority); if (fileEditParts.length > 0) { serialized.presentation = ToolInvocationPresentation.Hidden; } @@ -143,14 +146,14 @@ export function turnsToHistory(backendSession: URI, turns: readonly ITurn[], par * reasoning, completed tool calls) and live {@link ChatToolInvocation} * objects for running tool calls and pending confirmations. */ -export function activeTurnToProgress(sessionResource: URI, activeTurn: IActiveTurn): IChatProgress[] { +export function activeTurnToProgress(sessionResource: URI, activeTurn: IActiveTurn, connectionAuthority: string | undefined): IChatProgress[] { const parts: IChatProgress[] = []; for (const rp of activeTurn.responseParts) { switch (rp.kind) { case ResponsePartKind.Markdown: if (rp.content) { - parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content) }); + parts.push({ kind: 'markdownContent', content: rawMarkdownToString(rp.content, connectionAuthority) }); } break; case ResponsePartKind.Reasoning: @@ -161,9 +164,9 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: IActiveTu case ResponsePartKind.ToolCall: { const tc = rp.toolCall; if (tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Cancelled) { - parts.push(completedToolCallToSerialized(tc as ICompletedToolCall, undefined, sessionResource)); + parts.push(completedToolCallToSerialized(tc as ICompletedToolCall, undefined, sessionResource, connectionAuthority)); } else if (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Streaming || tc.status === ToolCallStatus.PendingConfirmation) { - parts.push(toolCallStateToInvocation(tc, undefined, sessionResource)); + parts.push(toolCallStateToInvocation(tc, undefined, sessionResource, connectionAuthority)); } break; } @@ -199,11 +202,11 @@ function getTerminalLanguage(tc: IToolCallState) { * Converts a completed tool call from the protocol state into a serialized * tool invocation suitable for history replay. */ -export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentInvocationId: string | undefined, sessionResource: URI): IChatToolInvocationSerialized { +export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string | undefined): IChatToolInvocationSerialized { const terminalContentUri = tc.status === ToolCallStatus.Completed ? getTerminalContentUri(tc.content) : undefined; const isTerminal = !!terminalContentUri; const isSuccess = tc.status === ToolCallStatus.Completed && tc.success; - const invocationMsg = stringOrMarkdownToString(tc.invocationMessage) ?? localize('ahp.running', "Running {0}...", tc.displayName); + const invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); // Check for subagent content const subagentContent = tc.status === ToolCallStatus.Completed ? getToolSubagentContent(tc) : undefined; @@ -211,7 +214,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn if (isSubagent && tc.status === ToolCallStatus.Completed) { const resultText = getToolOutputText(tc); const pastTenseMsg = isSuccess - ? stringOrMarkdownToString(tc.pastTenseMessage) ?? invocationMsg + ? stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority) ?? invocationMsg : invocationMsg; return { kind: 'toolInvocationSerialized', @@ -250,7 +253,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn } const pastTenseMsg = isSuccess - ? stringOrMarkdownToString(tc.pastTenseMessage) ?? invocationMsg + ? stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority) ?? invocationMsg : invocationMsg; return { @@ -319,21 +322,146 @@ export function completedToolCallToEditParts(tc: ICompletedToolCall): IChatProgr * Creates a live {@link ChatToolInvocation} from the protocol's tool-call * state. Used during active turns to represent running tool calls in the UI. */ +/** + * URI schemes that should NOT be rewritten when they appear inside markdown + * links received from a remote agent host. These are links that are + * meaningful outside the agent host's workspace (e.g. web links, VS Code + * commands) or are already wrapped in the agent-host scheme. + */ +const EXTERNAL_LINK_SCHEMES: ReadonlySet = new Set([ + 'http', + 'https', + 'mailto', + 'ws', + 'wss', + 'ftp', + 'ftps', + 'data', + 'blob', + 'javascript', + 'command', + 'vscode', + 'vscode-insiders', + AGENT_HOST_SCHEME, +]); + +/** + * Rewrites inline markdown link URIs so that non-external schemes are wrapped + * in the `vscode-agent-host://` scheme, mirroring {@link toAgentHostUri}. + * This allows links in markdown content streamed from a remote agent host + * (e.g. `file:///...` or `agenthost-content:///...`) to resolve correctly on + * the client through the agent host filesystem provider. + * + * Links with external schemes (http, https, mailto, command, etc.) and + * relative/anchor-only links without a scheme are preserved as-is. The + * markdown is parsed with marked and each `link` / `image` token is + * rewritten individually, so link-looking text inside code spans or fenced + * code blocks is untouched (marked emits those as `code`/`codespan` tokens + * with no nested link tokens). + */ +export function rewriteMarkdownLinks(markdown: string, connectionAuthority: string): string { + let tokens: TokensList; + try { + tokens = marked.lexer(markdown); + } catch { + return markdown; + } + + const edits: { raw: string; replacement: string }[] = []; + marked.walkTokens(tokens, token => { + if (token.type !== 'link' && token.type !== 'image') { + return; + } + const replacement = rewriteLinkTokenRaw(token as Tokens.Link | Tokens.Image, connectionAuthority); + if (replacement !== undefined) { + edits.push({ raw: (token as Token & { raw: string }).raw, replacement }); + } + }); + + if (edits.length === 0) { + return markdown; + } + + // Apply edits sequentially against the original markdown. walkTokens + // visits tokens in document order so a forward scan is sufficient. + let out = ''; + let pos = 0; + for (const { raw, replacement } of edits) { + const idx = markdown.indexOf(raw, pos); + if (idx < 0) { + continue; + } + out += markdown.substring(pos, idx) + replacement; + pos = idx + raw.length; + } + return out + markdown.substring(pos); +} + +/** + * Computes the rewritten `raw` string for a single link or image token, + * or returns `undefined` if the token should be left alone (external + * scheme or unparseable URI). + * + * The output collapses to the canonical inline form `[](newHref)` (or + * `![](newHref)` for images) — the chat renderer has richer handling for + * empty-text agent-host links, so preserving the original label isn't + * useful. This also means autolinks (``) and reference-style links + * (`[text][ref]`) are normalized into the inline form. + */ +function rewriteLinkTokenRaw(token: Tokens.Link | Tokens.Image, connectionAuthority: string): string | undefined { + let parsed: URI; + try { + parsed = URI.parse(token.href, true); + } catch { + return undefined; + } + const scheme = parsed.scheme.toLowerCase(); + if (!scheme || EXTERNAL_LINK_SCHEMES.has(scheme)) { + return undefined; + } + const newHref = toAgentHostUri(parsed, connectionAuthority).toString(); + const prefix = token.type === 'image' ? '![' : '['; + return `${prefix}](${newHref})`; +} + +/** + * Wraps a raw markdown string into an {@link IMarkdownString}, rewriting + * link URIs through {@link rewriteMarkdownLinks} when a connection authority + * is provided. + */ +export function rawMarkdownToString(content: string, connectionAuthority: string | undefined, options?: { supportHtml?: boolean }): MarkdownString { + const rewritten = connectionAuthority ? rewriteMarkdownLinks(content, connectionAuthority) : content; + return new MarkdownString(rewritten, options); +} + /** * Converts a protocol `StringOrMarkdown` value to a chat-layer `IMarkdownString`. + * + * When `connectionAuthority` is provided, markdown link URIs are rewritten + * through {@link rewriteMarkdownLinks} so that remote resources resolve + * through the agent host filesystem provider. */ -function stringOrMarkdownToString(value: string | { readonly markdown: string } | undefined): string | IMarkdownString | undefined { +export function stringOrMarkdownToString(value: StringOrMarkdown, connectionAuthority: string | undefined): string | IMarkdownString; +export function stringOrMarkdownToString(value: StringOrMarkdown | undefined, connectionAuthority: string | undefined): string | IMarkdownString | undefined; +export function stringOrMarkdownToString(value: StringOrMarkdown | undefined, connectionAuthority: string | undefined): string | IMarkdownString | undefined { if (value === undefined) { return undefined; } - return typeof value === 'string' ? value : new MarkdownString(value.markdown); + if (typeof value === 'string') { + return value; + } + return rawMarkdownToString(value.markdown, connectionAuthority); } /** * Creates a live {@link ChatToolInvocation} from the protocol's tool-call * state. Used during active turns to represent running tool calls in the UI. + * + * @param connectionAuthority Sanitized connection identifier used when + * wrapping remote file URIs into `vscode-agent-host:` URIs. Omit to skip + * URI wrapping (e.g. in tests that don't exercise the confirmation UI). */ -export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI): ChatToolInvocation { +export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string | undefined): ChatToolInvocation { const toolData: IToolData = { id: tc.toolName, source: ToolDataSource.Internal, @@ -343,15 +471,37 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation if (tc.status === ToolCallStatus.PendingConfirmation) { // Tool needs confirmation — create with confirmation messages - const titleText = stringOrMarkdownToString(tc.confirmationTitle) ?? stringOrMarkdownToString(tc.invocationMessage) ?? tc.displayName; - const titleStr = typeof titleText === 'string' ? titleText : titleText?.value ?? ''; const confirmationMessages: IToolConfirmationMessages = { - title: typeof titleText === 'string' ? new MarkdownString(titleText) : (titleText ?? new MarkdownString('')), - message: new MarkdownString(''), + title: stringOrMarkdownToString(tc.confirmationTitle, connectionAuthority) ?? tc.displayName, + message: stringOrMarkdownToString(tc.invocationMessage, connectionAuthority), }; - let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | undefined; - if (getToolKind(tc) === 'terminal' && tc.toolInput) { + let toolSpecificData: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatModifiedFilesConfirmationData | undefined; + const pendingEdits = tc.edits?.items; + if (pendingEdits && pendingEdits.length > 0) { + const wrap = (uri: URI) => connectionAuthority ? toAgentHostUri(uri, connectionAuthority) : uri; + const mapped = mapFileEdits(pendingEdits, tc.toolCallId); + toolSpecificData = { + kind: 'modifiedFilesConfirmation', + options: ['Allow'], + modifiedFiles: mapped.map(edit => { + const resource = wrap(edit.resource); + const originalResource = edit.originalResource ? wrap(edit.originalResource) : undefined; + const modifiedContent = edit.afterContentUri ? wrap(edit.afterContentUri) : undefined; + const originalContent = edit.beforeContentUri ? wrap(edit.beforeContentUri) : undefined; + return { + uri: resource, + originalUri: originalResource, + modifiedContentUri: modifiedContent, + originalContentUri: originalContent, + insertions: edit.diff?.added, + deletions: edit.diff?.removed, + title: basename(edit.resource), + description: edit.resource.path, + }; + }), + }; + } else if (getToolKind(tc) === 'terminal' && tc.toolInput) { toolSpecificData = { kind: 'terminal', commandLine: { original: getTerminalInput(tc) || '' }, @@ -365,7 +515,7 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation return new ChatToolInvocation( { - invocationMessage: typeof titleText === 'string' ? new MarkdownString(titleStr) : (titleText ?? new MarkdownString('')), + invocationMessage: stringOrMarkdownToString(tc.invocationMessage, connectionAuthority), confirmationMessages, presentation: ToolInvocationPresentation.HiddenAfterComplete, toolSpecificData, @@ -378,7 +528,7 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation } const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, subAgentInvocationId, undefined); - invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage) ?? localize('ahp.running', "Running {0}...", tc.displayName); + invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); const terminalContentUri = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) ? getTerminalContentUri(tc.content) @@ -425,13 +575,11 @@ export function toolCallStateToInvocation(tc: IToolCallState, subAgentInvocation * Called from the session handler when a tool transitions to Running state * to set the initial `toolSpecificData`, or when content changes arrive. */ -export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: IToolCallState): void { +export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: IToolCallState, connectionAuthority: string | undefined): void { if (tc.status !== ToolCallStatus.Running) { return; } - existing.invocationMessage = typeof tc.invocationMessage === 'string' - ? tc.invocationMessage - : new MarkdownString(tc.invocationMessage.markdown); + existing.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? existing.invocationMessage; const subagentContent = getToolSubagentContent(tc); @@ -475,7 +623,7 @@ export interface IToolCallFileEdit { * Returns file edits that the caller should route through the editing * session's external edits pipeline. */ -export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState, backendSession: URI): IToolCallFileEdit[] { +export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: IToolCallState, backendSession: URI, connectionAuthority: string | undefined): IToolCallFileEdit[] { const isCompleted = tc.status === ToolCallStatus.Completed; const isCancelled = tc.status === ToolCallStatus.Cancelled; const terminalContentUri = tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed @@ -484,7 +632,7 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ITool const isTerminal = invocation.toolSpecificData?.kind === 'terminal' || !!terminalContentUri; if ((isCompleted || isCancelled) && hasKey(tc, { invocationMessage: true })) { - invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage) ?? invocation.invocationMessage; + invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? invocation.invocationMessage; } // Check for subagent content — set toolSpecificData so the UI renders a subagent widget @@ -514,7 +662,7 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ITool terminalCommandUri: terminalContentUri ? URI.parse(terminalContentUri) : existing?.terminalCommandUri, }; } else if (isCompleted && tc.pastTenseMessage) { - invocation.pastTenseMessage = stringOrMarkdownToString(tc.pastTenseMessage); + invocation.pastTenseMessage = stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority); } const isFailure = (isCompleted && !tc.success) || isCancelled; @@ -539,8 +687,18 @@ export function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[ if (edits.length === 0) { return []; } + return mapFileEdits(edits, tc.toolCallId); +} + +/** + * Translates a list of {@link IFileEdit} records into {@link IToolCallFileEdit} + * entries suitable for the external edits pipeline or the chat modified-files + * confirmation UI. Shared between completed tool edits and pending write + * confirmations. + */ +function mapFileEdits(items: readonly IFileEdit[], undoStopId: string): IToolCallFileEdit[] { const result: IToolCallFileEdit[] = []; - for (const edit of edits) { + for (const edit of items) { const isCreate = !edit.before && !!edit.after; const isDelete = !!edit.before && !edit.after; const isRename = !!edit.before && !!edit.after && !isEqual(URI.parse(edit.before.uri), URI.parse(edit.after.uri)); @@ -567,7 +725,7 @@ export function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[ originalResource: isRename ? URI.parse(edit.before!.uri) : undefined, beforeContentUri: edit.before?.content.uri ? URI.parse(edit.before.content.uri) : undefined, afterContentUri: edit.after?.content.uri ? URI.parse(edit.after.content.uri) : undefined, - undoStopId: tc.toolCallId, + undoStopId, diff: edit.diff, }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 4bc9e0227ea2d..fb72fd4e6cb02 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -817,6 +817,13 @@ export class AICustomizationListWidget extends Disposable { }); } + /** + * Prepends an element to the search row (left of the search input). + */ + prependToSearchRow(element: HTMLElement): void { + this.searchAndButtonContainer.insertBefore(element, this.searchAndButtonContainer.firstChild); + } + /** * Sets the current section and loads items for that section. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 88bf77d057ef7..a016b959f84f2 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -780,6 +780,24 @@ export class AICustomizationManagementEditor extends EditorPane { this.welcomePage.rebuildCards(new Set(this.sections.map(s => s.id))); } + private createBackArrowButton(): HTMLButtonElement { + const button = $('button.section-back-arrow-button') as HTMLButtonElement; + button.type = 'button'; + button.setAttribute('aria-label', localize('backToOverview', "Back to overview")); + this.editorDisposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), button, localize('backToOverviewTooltip', "Back to overview"))); + const icon = DOM.append(button, $('span.section-back-arrow-icon')); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.arrowLeft)); + icon.setAttribute('aria-hidden', 'true'); + this.editorDisposables.add(DOM.addDisposableListener(button, 'click', () => { + this.showWelcomePage(); + })); + return button; + } + + private injectBackArrowIntoSearchRow(widget: { prependToSearchRow(el: HTMLElement): void }): void { + widget.prependToSearchRow(this.createBackArrowButton()); + } + private createContent(): void { const contentInner = DOM.append(this.contentContainer, $('.content-inner')); @@ -790,6 +808,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.promptsContentContainer = DOM.append(contentInner, $('.prompts-content-container')); this.listWidget = this.editorDisposables.add(this.instantiationService.createInstance(AICustomizationListWidget)); this.promptsContentContainer.appendChild(this.listWidget.element); + this.injectBackArrowIntoSearchRow(this.listWidget); // Handle item selection this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { @@ -818,6 +837,8 @@ export class AICustomizationManagementEditor extends EditorPane { const hasSections = new Set(this.workspaceService.managementSections); if (hasSections.has(AICustomizationManagementSection.Models)) { this.modelsContentContainer = DOM.append(contentInner, $('.models-content-container')); + const modelsBackBar = DOM.append(this.modelsContentContainer, $('.section-back-bar')); + modelsBackBar.appendChild(this.createBackArrowButton()); this.modelsWidget = this.editorDisposables.add(this.instantiationService.createInstance(ChatModelsWidget)); this.modelsContentContainer.appendChild(this.modelsWidget.element); @@ -838,6 +859,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.mcpContentContainer = DOM.append(contentInner, $('.mcp-content-container')); this.mcpListWidget = this.editorDisposables.add(this.instantiationService.createInstance(McpListWidget)); this.mcpContentContainer.appendChild(this.mcpListWidget.element); + this.injectBackArrowIntoSearchRow(this.mcpListWidget); // Embedded MCP server detail view this.mcpDetailContainer = DOM.append(contentInner, $('.mcp-detail-container')); @@ -857,6 +879,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.pluginContentContainer = DOM.append(contentInner, $('.plugin-content-container')); this.pluginListWidget = this.editorDisposables.add(this.instantiationService.createInstance(PluginListWidget)); this.pluginContentContainer.appendChild(this.pluginListWidget.element); + this.injectBackArrowIntoSearchRow(this.pluginListWidget); // Embedded plugin detail view this.pluginDetailContainer = DOM.append(contentInner, $('.plugin-detail-container')); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index a7b806398d017..33a229e24a21b 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -5,7 +5,7 @@ import './media/aiCustomizationManagement.css'; import * as DOM from '../../../../../base/browser/dom.js'; -import { Disposable, DisposableStore, isDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, isDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../../base/common/event.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -16,6 +16,8 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, McpConnectionState, McpServerInstallState, IMcpService, IMcpServer } from '../../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../mcp/common/mcpRegistryTypes.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; @@ -371,6 +373,10 @@ export class McpListWidget extends Disposable { private emptyContainer!: HTMLElement; private emptyText!: HTMLElement; private emptySubtext!: HTMLElement; + private disabledContainer!: HTMLElement; + private disabledIcon!: HTMLElement; + private disabledMessage!: HTMLElement; + private readonly disabledLinkListener = this._register(new MutableDisposable()); private browseButton!: Button; private addButton!: Button; private backLink!: HTMLElement; @@ -400,10 +406,17 @@ export class McpListWidget extends Disposable { @IHoverService private readonly hoverService: IHoverService, @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @IDialogService private readonly dialogService: IDialogService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this.element = $('.mcp-list-widget'); this.create(); + this.updateAccessState(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(mcpAccessConfig)) { + this.updateAccessState(); + } + })); this._register({ dispose: () => { this.galleryCts?.dispose(); @@ -485,6 +498,15 @@ export class McpListWidget extends Disposable { this.emptyText = DOM.append(emptyHeader, $('.empty-text')); this.emptySubtext = DOM.append(this.emptyContainer, $('.empty-subtext')); + // Disabled (access blocked) state — shown when chat.mcp.access is set to none, + // either by user setting or by enterprise policy. + this.disabledContainer = DOM.append(this.element, $('.mcp-disabled-state')); + const disabledHeader = DOM.append(this.disabledContainer, $('.empty-state-header')); + this.disabledIcon = DOM.append(disabledHeader, $('.empty-icon')); + const disabledText = DOM.append(disabledHeader, $('.empty-text')); + disabledText.textContent = localize('mcpAccessDisabledTitle', "MCP servers are disabled"); + this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); + // List container this.listContainer = DOM.append(this.element, $('.mcp-list-container')); @@ -587,6 +609,36 @@ export class McpListWidget extends Disposable { } } + private updateAccessState(): void { + const inspect = this.configurationService.inspect(mcpAccessConfig); + const value = inspect.value ?? inspect.defaultValue; + const disabled = value === McpAccessValue.None; + const policyLocked = inspect.policyValue === McpAccessValue.None; + + this.element.classList.toggle('access-disabled', disabled); + + if (disabled) { + this.disabledIcon.className = 'empty-icon'; + this.disabledIcon.classList.add(...ThemeIcon.asClassNameArray(policyLocked ? Codicon.shield : mcpServerIcon)); + + DOM.clearNode(this.disabledMessage); + this.disabledLinkListener.clear(); + if (policyLocked) { + this.disabledMessage.textContent = localize('mcpAccessDisabledByPolicy', "Access to MCP servers is disabled by your organization. Contact your organization administrator for more information."); + } else { + this.disabledMessage.appendChild(document.createTextNode(localize('mcpAccessDisabledBySettingPrefix', "MCP servers are disabled in settings. "))); + const link = DOM.append(this.disabledMessage, $('a.mcp-disabled-settings-link')) as HTMLAnchorElement; + link.textContent = localize('mcpAccessDisabledSettingLink', "Configure in settings."); + link.href = '#'; + link.setAttribute('role', 'button'); + this.disabledLinkListener.value = DOM.addDisposableListener(link, 'click', (e) => { + e.preventDefault(); + this.commandService.executeCommand('workbench.action.openSettings', `@id:${mcpAccessConfig}`); + }); + } + } + } + public showBrowseMarketplace(): void { if (!this.browseMode) { this.toggleBrowseMode(true); @@ -890,6 +942,14 @@ export class McpListWidget extends Disposable { this.filterServers(); } + /** + /** + * Prepends an element to the search row (left of the search input). + */ + prependToSearchRow(element: HTMLElement): void { + this.searchAndButtonContainer.insertBefore(element, this.searchAndButtonContainer.firstChild); + } + /** * Layouts the widget. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css index 20ceb8421ef03..f878d5f25d377 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -148,6 +148,47 @@ box-sizing: border-box; } +.ai-customization-management-editor .section-back-arrow-button { + flex-shrink: 0; + align-self: flex-start; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; + font-family: inherit; + opacity: 0.8; + transition: background-color 0.1s ease, opacity 0.1s ease; +} + +.ai-customization-management-editor .section-back-arrow-button:hover { + background-color: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); + opacity: 1; +} + +.ai-customization-management-editor .section-back-arrow-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.ai-customization-management-editor .section-back-arrow-icon { + font-size: 14px; + opacity: 0.85; +} + +.ai-customization-management-editor .section-back-bar { + flex-shrink: 0; + display: flex; + align-items: center; + margin-bottom: 8px; +} + /* List Widget */ .ai-customization-list-widget { display: flex; @@ -795,6 +836,73 @@ margin-top: 16px; } +/* MCP Disabled (access blocked) state */ +.mcp-list-widget .mcp-disabled-state { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 8px; + text-align: center; + flex: 1; +} + +.mcp-list-widget .mcp-disabled-state .empty-state-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.mcp-list-widget .mcp-disabled-state .empty-icon.codicon::before { + font-size: 48px; +} + +.mcp-list-widget .mcp-disabled-state .empty-icon { + opacity: 0.5; + margin-bottom: 8px; + height: 48px; + width: 48px; + display: flex; + align-items: center; + justify-content: center; +} + +.mcp-list-widget .mcp-disabled-state .empty-text { + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); +} + +.mcp-list-widget .mcp-disabled-state .empty-subtext { + font-size: 13px; + color: var(--vscode-descriptionForeground); + max-width: 420px; + line-height: 1.5; +} + +.mcp-list-widget .mcp-disabled-state .mcp-disabled-settings-link { + color: var(--vscode-textLink-foreground); + cursor: pointer; + text-decoration: none; +} + +.mcp-list-widget .mcp-disabled-state .mcp-disabled-settings-link:hover, +.mcp-list-widget .mcp-disabled-state .mcp-disabled-settings-link:focus { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; + outline: none; +} + +.mcp-list-widget.access-disabled > *:not(.mcp-disabled-state) { + display: none !important; +} + +.mcp-list-widget.access-disabled .mcp-disabled-state { + display: flex; +} + /* MCP Server Item */ .mcp-server-item { display: flex; @@ -982,17 +1090,19 @@ .ai-customization-management-editor .editor-header { display: flex; align-items: center; - gap: 12px; - padding: 4px 0; + gap: 8px; + padding: 6px 2px; flex-shrink: 0; } .ai-customization-management-editor .editor-back-button { + flex-shrink: 0; + align-self: flex-start; display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 26px; + height: 26px; border-radius: 4px; cursor: pointer; background: transparent; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts index 2ad8cd7119c04..25b106d85a0a5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/pluginListWidget.ts @@ -39,6 +39,8 @@ import { ILabelService } from '../../../../../platform/label/common/label.js'; import { CustomizationGroupHeaderRenderer, ICustomizationGroupHeaderEntry, CUSTOMIZATION_GROUP_HEADER_HEIGHT, CUSTOMIZATION_GROUP_HEADER_HEIGHT_WITH_SEPARATOR } from './customizationGroupHeaderRenderer.js'; import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../common/constants.js'; const $ = DOM.$; @@ -338,6 +340,10 @@ export class PluginListWidget extends Disposable { private emptyContainer!: HTMLElement; private emptyText!: HTMLElement; private emptySubtext!: HTMLElement; + private disabledContainer!: HTMLElement; + private disabledIcon!: HTMLElement; + private disabledMessage!: HTMLElement; + private readonly disabledLinkListener = this._register(new MutableDisposable()); private browseButton!: Button; private backLink!: HTMLElement; @@ -365,10 +371,17 @@ export class PluginListWidget extends Disposable { @ILabelService private readonly labelService: ILabelService, @ICommandService private readonly commandService: ICommandService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this.element = $('.mcp-list-widget'); // reuse MCP list widget CSS this.create(); + this.updateAccessState(); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.PluginsEnabled)) { + this.updateAccessState(); + } + })); this._register({ dispose: () => { this.marketplaceCts?.dispose(); @@ -453,6 +466,15 @@ export class PluginListWidget extends Disposable { this.emptyText = DOM.append(emptyHeader, $('.empty-text')); this.emptySubtext = DOM.append(this.emptyContainer, $('.empty-subtext')); + // Disabled (access blocked) state — shown when chat.plugins.enabled is false, + // either by user setting or by enterprise policy. + this.disabledContainer = DOM.append(this.element, $('.mcp-disabled-state')); + const disabledHeader = DOM.append(this.disabledContainer, $('.empty-state-header')); + this.disabledIcon = DOM.append(disabledHeader, $('.empty-icon')); + const disabledText = DOM.append(disabledHeader, $('.empty-text')); + disabledText.textContent = localize('pluginsDisabledTitle', "Plugins are disabled"); + this.disabledMessage = DOM.append(this.disabledContainer, $('.empty-subtext')); + // List container this.listContainer = DOM.append(this.element, $('.mcp-list-container')); @@ -583,6 +605,36 @@ export class PluginListWidget extends Disposable { } } + private updateAccessState(): void { + const inspect = this.configurationService.inspect(ChatConfiguration.PluginsEnabled); + const value = inspect.value ?? inspect.defaultValue; + const disabled = value === false; + const policyLocked = inspect.policyValue === false; + + this.element.classList.toggle('access-disabled', disabled); + + if (disabled) { + this.disabledIcon.className = 'empty-icon'; + this.disabledIcon.classList.add(...ThemeIcon.asClassNameArray(policyLocked ? Codicon.shield : pluginIcon)); + + DOM.clearNode(this.disabledMessage); + this.disabledLinkListener.clear(); + if (policyLocked) { + this.disabledMessage.textContent = localize('pluginsDisabledByPolicy', "Plugin integration in chat is disabled by your organization. Contact your organization administrator for more information."); + } else { + this.disabledMessage.appendChild(document.createTextNode(localize('pluginsDisabledBySettingPrefix', "Plugins are disabled in settings. "))); + const link = DOM.append(this.disabledMessage, $('a.mcp-disabled-settings-link')) as HTMLAnchorElement; + link.textContent = localize('pluginsDisabledSettingLink', "Configure in settings."); + link.href = '#'; + link.setAttribute('role', 'button'); + this.disabledLinkListener.value = DOM.addDisposableListener(link, 'click', (e) => { + e.preventDefault(); + this.commandService.executeCommand('workbench.action.openSettings', `@id:${ChatConfiguration.PluginsEnabled}`); + }); + } + } + } + public showBrowseMarketplace(): void { if (!this.browseMode) { this.toggleBrowseMode(true); @@ -786,6 +838,13 @@ export class PluginListWidget extends Disposable { this.filterPlugins(); } + /** + * Prepends an element to the search row (left of the search input). + */ + prependToSearchRow(element: HTMLElement): void { + this.searchAndButtonContainer.insertBefore(element, this.searchAndButtonContainer.firstChild); + } + layout(height: number, width: number): void { this.lastHeight = height; this.lastWidth = width; diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d01f50e0b8be4..568a6c0855baa 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -11,6 +11,7 @@ import { PolicyCategory } from '../../../../base/common/policy.js'; import { AgentHostEnabledSettingId, AgentHostIpcLoggingSettingId } from '../../../../platform/agentHost/common/agentService.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; @@ -349,6 +350,40 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.experimental.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."), default: null }, + [ChatConfiguration.IncrementalRendering]: { + type: 'boolean', + description: nls.localize('chat.experimental.incrementalRendering.enabled', "Enables incremental rendering with optional block-level animation when streaming chat responses."), + default: false, + tags: ['experimental'], + }, + [ChatConfiguration.IncrementalRenderingStyle]: { + type: 'string', + enum: ['none', 'fade', 'rise', 'blur', 'scale', 'slide', 'reveal'], + enumDescriptions: [ + nls.localize('chat.experimental.incrementalRendering.animationStyle.none', "No animation. Content appears instantly."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.fade', "Simple opacity fade from 0 to 1."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.rise', "Content fades in while rising upward."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.blur', "Content fades in from a blurred state."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.scale', "Content scales up from slightly smaller."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.slide', "Content slides in from the left."), + nls.localize('chat.experimental.incrementalRendering.animationStyle.reveal', "Content reveals top-to-bottom with a soft gradient edge."), + ], + description: nls.localize('chat.experimental.incrementalRendering.animationStyle', "Controls the animation style for incremental rendering."), + default: 'fade', + tags: ['experimental'], + }, + [ChatConfiguration.IncrementalRenderingBuffering]: { + type: 'string', + enum: ['off', 'word', 'paragraph'], + enumDescriptions: [ + nls.localize('chat.experimental.incrementalRendering.buffering.off', "Renders content immediately as tokens arrive."), + nls.localize('chat.experimental.incrementalRendering.buffering.word', "Reveals content word by word."), + nls.localize('chat.experimental.incrementalRendering.buffering.paragraph', "Buffers content until a paragraph break before rendering."), + ], + description: nls.localize('chat.experimental.incrementalRendering.buffering', "Controls how content is buffered before rendering during incremental rendering. Lower buffering levels render faster but may show incomplete sentences or partially formed markdown."), + default: 'word', + tags: ['experimental'], + }, 'chat.detectParticipant.enabled': { type: 'boolean', description: nls.localize('chat.detectParticipant.enabled', "Enables chat participant autodetection for panel chat."), @@ -800,7 +835,7 @@ configurationRegistry.registerConfiguration({ } }, [AgentNetworkDomainSettingId.NetworkFilter]: { - markdownDescription: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. When disabled, no network filtering is applied.", '`#chat.agent.allowedNetworkDomains#`', '`#chat.agent.deniedNetworkDomains#`'), + markdownDescription: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. Domain filtering is also applied to those tools when {2} is enabled.", `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``), type: 'boolean', default: false, restricted: true, @@ -811,13 +846,13 @@ configurationRegistry.registerConfiguration({ localization: { description: { key: 'chat.agent.networkFilter', - value: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. When disabled, no network filtering is applied.", '`#chat.agent.allowedNetworkDomains#`', '`#chat.agent.deniedNetworkDomains#`'), + value: nls.localize('chat.agent.networkFilter', "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to {0} and {1}. Domain filtering is also applied to those tools when {2} is enabled.", `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``), } } } }, [AgentNetworkDomainSettingId.AllowedNetworkDomains]: { - markdownDescription: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Only takes effect when {0} is enabled. When {1} is enabled, these also apply to the terminal sandbox. Supports wildcards like {2}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {3}) take precedence.", '`#chat.agent.networkFilter#`', '`#chat.agent.sandbox.enabled#`', '`*.example.com`', '`#chat.agent.deniedNetworkDomains#`'), + markdownDescription: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is enabled, this also configures terminal sandbox networking. Supports wildcards like {2}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {3}) take precedence.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), type: 'array', items: { type: 'string' }, default: [], @@ -829,13 +864,13 @@ configurationRegistry.registerConfiguration({ localization: { description: { key: 'chat.agent.allowedNetworkDomains', - value: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Only takes effect when {0} is enabled. When {1} is enabled, these also apply to the terminal sandbox. Supports wildcards like {2}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {3}) take precedence.", '`#chat.agent.networkFilter#`', '`#chat.agent.sandbox.enabled#`', '`*.example.com`', '`#chat.agent.deniedNetworkDomains#`'), + value: nls.localize('chat.agent.allowedNetworkDomains', "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is enabled, this also configures terminal sandbox networking. Supports wildcards like {2}. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see {3}) take precedence.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, '`*.example.com`', `\`#${AgentNetworkDomainSettingId.DeniedNetworkDomains}#\``), } } } }, [AgentNetworkDomainSettingId.DeniedNetworkDomains]: { - markdownDescription: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Only takes effect when {0} is enabled. When {1} is enabled, these also apply to the terminal sandbox. Takes precedence over {2}. Supports wildcards like {3}.", '`#chat.agent.networkFilter#`', '`#chat.agent.sandbox.enabled#`', '`#chat.agent.allowedNetworkDomains#`', '`*.example.com`'), + markdownDescription: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is enabled, this also configures terminal sandbox networking. Takes precedence over {2}. Supports wildcards like {3}.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), type: 'array', items: { type: 'string' }, default: [], @@ -847,7 +882,7 @@ configurationRegistry.registerConfiguration({ localization: { description: { key: 'chat.agent.deniedNetworkDomains', - value: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Only takes effect when {0} is enabled. When {1} is enabled, these also apply to the terminal sandbox. Takes precedence over {2}. Supports wildcards like {3}.", '`#chat.agent.networkFilter#`', '`#chat.agent.sandbox.enabled#`', '`#chat.agent.allowedNetworkDomains#`', '`*.example.com`'), + value: nls.localize('chat.agent.deniedNetworkDomains', "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when {0} or {1} is enabled. When {1} is enabled, this also configures terminal sandbox networking. Takes precedence over {2}. Supports wildcards like {3}.", `\`#${AgentNetworkDomainSettingId.NetworkFilter}#\``, `\`#${AgentSandboxSettingId.AgentSandboxEnabled}#\``, `\`#${AgentNetworkDomainSettingId.AllowedNetworkDomains}#\``, '`*.example.com`'), } } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animation.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animation.ts new file mode 100644 index 0000000000000..1b50c9f5b2d7b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animation.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Animation strategy for incremental rendering. Applied as a post-processing + * decoration after the markdown has been correctly rendered. + * + * Animation is separate from buffering — it controls *how* rendered + * content appears, while buffering controls *when* we render. + */ +export interface IIncrementalRenderingAnimation { + /** + * Apply entrance animation to newly appeared DOM children. + * + * @param children The live HTMLCollection of the container's children. + * @param fromIndex Index of the first new child to animate. + * @param currentCount Total number of children currently in the DOM. + * @param elapsed Milliseconds since the animation batch started. + */ + animate(children: HTMLCollection, fromIndex: number, currentCount: number, elapsed: number): void; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animationRegistry.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animationRegistry.ts new file mode 100644 index 0000000000000..d977ab3deea44 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/animationRegistry.ts @@ -0,0 +1,23 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IIncrementalRenderingAnimation } from './animation.js'; +import { BlockAnimation } from './blockAnimations.js'; + +/** + * Registry of all available animation styles. + * To add a new animation, add an entry here. + */ +export const ANIMATION_STYLES = { + none: (): IIncrementalRenderingAnimation => ({ animate() { } }), + fade: (): IIncrementalRenderingAnimation => new BlockAnimation('fade'), + rise: (): IIncrementalRenderingAnimation => new BlockAnimation('rise'), + blur: (): IIncrementalRenderingAnimation => new BlockAnimation('blur'), + scale: (): IIncrementalRenderingAnimation => new BlockAnimation('scale'), + slide: (): IIncrementalRenderingAnimation => new BlockAnimation('slide'), + reveal: (): IIncrementalRenderingAnimation => new BlockAnimation('reveal'), +} as const satisfies Record IIncrementalRenderingAnimation>; + +export type AnimationStyleName = keyof typeof ANIMATION_STYLES; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.ts new file mode 100644 index 0000000000000..72650fefb92c9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IIncrementalRenderingAnimation } from './animation.js'; + +/** Duration of the animation applied to newly rendered blocks. */ +export const ANIMATION_DURATION_MS = 600; + +/** + * Delay (ms) between each successive new child's animation start. + * Creates a cascading top-to-bottom reveal across a batch of new + * block-level elements. + */ +const STAGGER_DELAY_MS = 150; + +/** + * Block-level CSS animation styles: fade, rise, blur, scale, slide, + * and lineFade. Each applies a CSS class and staggered timing + * variables to new top-level children so they reveal sequentially. + */ +export class BlockAnimation implements IIncrementalRenderingAnimation { + + constructor(private readonly _style: 'fade' | 'rise' | 'blur' | 'scale' | 'slide' | 'reveal') { } + + animate(children: HTMLCollection, fromIndex: number, currentCount: number, elapsed: number): void { + const className = `chat-smooth-animate-${this._style}`; + + for (let i = fromIndex; i < currentCount; i++) { + const child = children[i] as HTMLElement; + if (!child.classList) { + continue; + } + + const staggerOffset = (i - fromIndex) * STAGGER_DELAY_MS; + const childDelay = -elapsed + staggerOffset; + + child.classList.add(className); + child.style.setProperty('--chat-smooth-duration', `${ANIMATION_DURATION_MS}ms`); + child.style.setProperty('--chat-smooth-delay', `${childDelay}ms`); + + child.addEventListener('animationend', (e) => { + if (e.target !== child) { + return; + } + child.classList.remove(className); + child.style.removeProperty('--chat-smooth-duration'); + child.style.removeProperty('--chat-smooth-delay'); + }, { once: true }); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/buffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/buffer.ts new file mode 100644 index 0000000000000..6864e07847274 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/buffer.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * A buffering strategy determines how much incoming markdown content + * must accumulate before a render is triggered. + * + * Buffering is separate from animation — it controls *when* we render, + * while animation controls *how* rendered content appears. + */ +export interface IIncrementalRenderingBuffer { + /** + * Given the full markdown string and the markdown that was last + * rendered to the real DOM, return `true` if the buffer should + * be handled entirely within _flushRender (e.g. shadow measurement). + * In that case the orchestrator should pass everything through + * without updating `_renderedMarkdown`. + */ + readonly handlesFlush: boolean; + + /** + * Determine the renderable prefix of `fullMarkdown`. The returned + * string must be a prefix of `fullMarkdown` (or `fullMarkdown` + * itself). Content beyond the returned prefix stays buffered. + * + * @param fullMarkdown The complete markdown accumulated so far. + * @param lastRendered The markdown last rendered to the DOM. + * @returns The prefix to render now. + */ + getRenderable(fullMarkdown: string, lastRendered: string): string; + + /** + * For buffers that handle flushing themselves (e.g. line buffer + * with shadow DOM measurement), this is called during + * `_flushRender` to decide whether to commit the pending content. + * + * @param markdown The pending markdown to potentially commit. + * @returns The markdown to actually commit, or `undefined` to skip. + */ + filterFlush?(markdown: string): string | undefined; + + /** + * Whether the buffer needs another rAF frame to continue revealing + * content (e.g. typewriter drip-feeding words). When `true`, the + * orchestrator re-schedules a render after the current flush. + */ + readonly needsNextFrame?: boolean; + + /** + * Called when the buffer is no longer needed. + */ + dispose?(): void; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/bufferRegistry.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/bufferRegistry.ts new file mode 100644 index 0000000000000..9159d910c5b88 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/bufferRegistry.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IIncrementalRenderingBuffer } from './buffer.js'; +import { OffBuffer } from './offBuffer.js'; +import { ParagraphBuffer } from './paragraphBuffer.js'; +import { WordBuffer } from './wordBuffer.js'; + +/** + * Registry of all available buffering strategies. + * To add a new buffer, add an entry here. + */ +export const BUFFER_MODES = { + off: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new OffBuffer(), + word: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new WordBuffer(), + paragraph: (_domNode: HTMLElement): IIncrementalRenderingBuffer => new ParagraphBuffer(), +} as const satisfies Record IIncrementalRenderingBuffer>; + +export type BufferModeName = keyof typeof BUFFER_MODES; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/offBuffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/offBuffer.ts new file mode 100644 index 0000000000000..2608716e1fedf --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/offBuffer.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IIncrementalRenderingBuffer } from './buffer.js'; + +/** + * No buffering — renders everything immediately as tokens arrive. + * Content is still rAF-coalesced by the orchestrator. + */ +export class OffBuffer implements IIncrementalRenderingBuffer { + readonly handlesFlush = false; + + getRenderable(fullMarkdown: string, _lastRendered: string): string { + return fullMarkdown; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts new file mode 100644 index 0000000000000..f6258db1a3fc7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IIncrementalRenderingBuffer } from './buffer.js'; + +/** + * Maximum number of characters that may accumulate beyond the last + * paragraph boundary before a render is forced. + */ +const MAX_BUFFERED_CHARS = 4000; + +/** + * Finds the last `\n\n` block boundary that is NOT inside an open + * fenced code block. This prevents splitting a render in the middle + * of a code fence, which would cause the code block element to update + * in place (same DOM index) without triggering a new-child animation. + * + * The scan counts backtick-fence openings/closings from the start of + * the string. A `\n\n` is only a valid boundary when the fence depth + * is 0 (i.e. outside any code block). + * + * @internal Exported for testing. + */ +export function lastBlockBoundary(text: string): number { + let lastValid = -1; + let inFence = false; + + for (let i = 0; i < text.length; i++) { + // Detect fenced code blocks: ``` or ~~~ at the start of a line. + if ((i === 0 || text[i - 1] === '\n') && + ((text[i] === '`' && text[i + 1] === '`' && text[i + 2] === '`') || + (text[i] === '~' && text[i + 1] === '~' && text[i + 2] === '~'))) { + inFence = !inFence; + i += 2; // skip past the triple backtick/tilde + continue; + } + // Detect block boundary outside code fences. + if (!inFence && text[i] === '\n' && text[i + 1] === '\n') { + lastValid = i; + } + } + + return lastValid; +} + +/** + * Buffers content at paragraph boundaries (`\n\n` outside code fences). + * This avoids rendering partially formed blocks — text mid-paragraph, + * incomplete list groups, or half a code fence. + */ +export class ParagraphBuffer implements IIncrementalRenderingBuffer { + readonly handlesFlush = false; + + getRenderable(fullMarkdown: string, lastRendered: string): string { + const lastBlock = lastBlockBoundary(fullMarkdown); + let renderable = lastBlock === -1 + ? lastRendered // no complete block yet — keep current + : fullMarkdown.slice(0, lastBlock + 2); + + // Escape hatch: if too much content has accumulated without a + // block boundary, render what we have. + if (fullMarkdown.length - renderable.length > MAX_BUFFERED_CHARS) { + renderable = fullMarkdown; + } + + return renderable; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.ts new file mode 100644 index 0000000000000..0473987dac815 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.ts @@ -0,0 +1,128 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getNWords } from '../../../../../common/model/chatWordCounter.js'; +import { IIncrementalRenderingBuffer } from './buffer.js'; + +/** + * Minimum reveal rate in words/sec. Ensures content always progresses + * even when the estimated rate is very low or unknown. + */ +const MIN_RATE = 40; + +/** + * Maximum reveal rate in words/sec. Caps the rate to prevent + * dumping too much content at once. + */ +const MAX_RATE = 2000; + +/** + * Minimum rate used after the response is complete, to drain + * buffered content quickly. + */ +const MIN_RATE_AFTER_COMPLETE = 80; + +/** + * Fallback rate when no estimate is available yet. + */ +const DEFAULT_RATE = 8; + +/** + * Word buffer: drip-feeds words at a rate matching the model's + * token production speed, similar to the original 50ms progressive + * render but driven by rAF for smoother output. + * + * The reveal rate is set externally via {@link setRate} from the + * model's `impliedWordLoadRate` estimate. Words are revealed based + * on elapsed time since the last render, so the output speed + * naturally matches the model's generation speed. + */ +export class WordBuffer implements IIncrementalRenderingBuffer { + readonly handlesFlush = true; + + /** The full markdown received so far. */ + private _fullMarkdown: string = ''; + + /** Number of words currently revealed to the DOM. */ + private _revealedWordCount: number = 0; + + /** The markdown string last committed to the DOM. */ + private _lastCommittedMarkdown: string = ''; + + /** Whether there are still unrevealed words to show. */ + private _needsNextFrame: boolean = false; + + /** Timestamp of the last successful commit. */ + private _lastCommitTime: number = 0; + + /** Estimated word production rate (words/sec). */ + private _rate: number = DEFAULT_RATE; + + get needsNextFrame(): boolean { + return this._needsNextFrame; + } + + /** + * Set the estimated word production rate from the model's + * `impliedWordLoadRate`. Called by the orchestrator. + */ + setRate(rate: number | undefined, isComplete: boolean): void { + if (isComplete) { + this._rate = typeof rate === 'number' + ? Math.max(rate, MIN_RATE_AFTER_COMPLETE) + : MIN_RATE_AFTER_COMPLETE; + } else { + this._rate = typeof rate === 'number' + ? Math.min(Math.max(rate, MIN_RATE), MAX_RATE) + : DEFAULT_RATE; + } + } + + getRenderable(fullMarkdown: string, _lastRendered: string): string { + this._fullMarkdown = fullMarkdown; + return fullMarkdown; + } + + filterFlush(markdown: string): string | undefined { + this._fullMarkdown = markdown; + + const now = Date.now(); + if (this._lastCommitTime === 0) { + // First frame — reveal 1 word to get started. + this._lastCommitTime = now; + this._revealedWordCount = 1; + } else { + // Compute how many words to reveal based on elapsed time + // and the estimated rate, matching the original approach. + const elapsed = now - this._lastCommitTime; + const newWords = Math.floor(elapsed / 1000 * this._rate); + if (newWords > 0) { + this._revealedWordCount += newWords; + this._lastCommitTime = now; + } + } + + const result = getNWords(this._fullMarkdown, this._revealedWordCount); + + if (result.isFullString) { + this._needsNextFrame = false; + // Reset to the actual word count so that when new tokens + // arrive, drip-feeding resumes from the correct position + // instead of instantly dumping everything. + this._revealedWordCount = result.returnedWordCount; + this._lastCommittedMarkdown = this._fullMarkdown; + return this._fullMarkdown; + } + + this._needsNextFrame = true; + + if (result.value.length <= this._lastCommittedMarkdown.length) { + return undefined; + } + + this._lastCommittedMarkdown = result.value; + return result.value; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts new file mode 100644 index 0000000000000..80ecb6679d800 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.ts @@ -0,0 +1,291 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatIncrementalRendering.css'; +import { getWindow } from '../../../../../../../base/browser/dom.js'; +import { Disposable } from '../../../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; +import { IIncrementalRenderingBuffer } from './buffers/buffer.js'; +import { WordBuffer } from './buffers/wordBuffer.js'; +import { BUFFER_MODES, BufferModeName } from './buffers/bufferRegistry.js'; +import { IIncrementalRenderingAnimation } from './animations/animation.js'; +import { ANIMATION_STYLES, AnimationStyleName } from './animations/animationRegistry.js'; +import { ANIMATION_DURATION_MS } from './animations/blockAnimations.js'; + +/** + * Incremental markdown streaming renderer — rAF-batched, append-only. + * + * Orchestrates two independent concerns: + * - **Buffering** (when to render): controlled by an {@link IIncrementalRenderingBuffer}. + * - **Animation** (how it appears): controlled by an {@link IIncrementalRenderingAnimation}. + * + * The renderer works *with* the existing markdown rendering pipeline. + * Each update re-renders through the standard `doRenderMarkdown()` path, + * so code blocks, tables, KaTeX, and all markdown features render correctly. + * + * If the new markdown is NOT a pure append, `tryMorph()` returns `false` + * and the caller falls back to a full re-render. + */ +export class IncrementalDOMMorpher extends Disposable { + + private _lastMarkdown: string = ''; + + /** + * The markdown that was last rendered to the DOM. May lag behind + * `_lastMarkdown` while content is being buffered. + */ + private _renderedMarkdown: string = ''; + + /** + * High-water mark: the number of top-level children that have been + * fully revealed. Children at indices >= this value are "new" + * and get animated on each render. + */ + private _revealedChildCount: number = 0; + + /** + * Timestamp when children at indices >= `_revealedChildCount` + * first appeared. 0 means no animation is in progress. + */ + private _animationStartTime: number = 0; + + /** + * The total child count at the end of the most recent render in + * the current animation batch. + */ + private _batchChildCount: number = 0; + + private _rafScheduled: boolean = false; + private _pendingMarkdown: string | undefined; + private _rafHandle: number | undefined; + private _renderCallback: ((newMarkdown: string) => void) | undefined; + + private _buffer: IIncrementalRenderingBuffer; + private _animation: IIncrementalRenderingAnimation; + + constructor( + private readonly _domNode: HTMLElement, + @IConfigurationService private readonly _configService: IConfigurationService, + ) { + super(); + this._buffer = this._createBuffer(); + this._animation = this._createAnimation(); + + this._register(this._configService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(ChatConfiguration.IncrementalRenderingStyle)) { + this._animation = this._createAnimation(); + } + if (e.affectsConfiguration(ChatConfiguration.IncrementalRenderingBuffering)) { + this._buffer.dispose?.(); + this._buffer = this._createBuffer(); + } + })); + } + + // ---- strategy factories ---- + + private _createBuffer(): IIncrementalRenderingBuffer { + const raw = this._configService.getValue(ChatConfiguration.IncrementalRenderingBuffering); + const factory = Object.prototype.hasOwnProperty.call(BUFFER_MODES, raw) + ? BUFFER_MODES[raw as BufferModeName] + : BUFFER_MODES.paragraph; + return factory(this._domNode); + } + + private _createAnimation(): IIncrementalRenderingAnimation { + const raw = this._configService.getValue(ChatConfiguration.IncrementalRenderingStyle); + const factory = Object.prototype.hasOwnProperty.call(ANIMATION_STYLES, raw) + ? ANIMATION_STYLES[raw as AnimationStyleName] + : ANIMATION_STYLES.fade; + return factory(); + } + + // ---- public API ---- + + /** + * Register the callback that performs the actual markdown re-render. + */ + setRenderCallback(cb: (newMarkdown: string) => void): void { + this._renderCallback = cb; + } + + /** + * Forward the stream's word-rate estimate to the active buffer + * (word buffer or line buffer). + */ + updateStreamRate(rate: number, isComplete: boolean): void { + if (this._buffer instanceof WordBuffer) { + this._buffer.setRate(rate, isComplete); + } + } + + /** + * Seeds the renderer with the initial markdown string. + * + * @param animateInitial When `true`, the children already in the + * DOM receive the entrance animation. + */ + seed(markdown: string, animateInitial?: boolean): void { + this._lastMarkdown = markdown; + this._animationStartTime = 0; + + // For drip-feed buffers (word), clear the DOM and let the + // buffer reveal content from scratch — the initial + // doRenderMarkdown() ran to initialize pipeline state but + // the visible content should be built up by the buffer. + if (this._buffer.handlesFlush && markdown.length > 0) { + this._renderedMarkdown = ''; + this._revealedChildCount = 0; + // Clear the DOM so the buffer starts from empty. + while (this._domNode.firstChild) { + this._domNode.removeChild(this._domNode.firstChild); + } + // Schedule the first drip-feed render. + this._pendingMarkdown = markdown; + this._scheduleRender(); + return; + } + + this._renderedMarkdown = markdown; + this._revealedChildCount = animateInitial ? 0 : this._domNode.children.length; + if (animateInitial) { + this._animateNewChildren(); + } + } + + /** + * Attempts an incremental DOM update via rAF-batched re-render. + * + * @returns `true` if absorbed, `false` if a full re-render is needed. + */ + tryMorph(newMarkdown: string): boolean { + if (!newMarkdown.startsWith(this._lastMarkdown)) { + return false; + } + + const appended = newMarkdown.slice(this._lastMarkdown.length); + if (appended.length === 0) { + return true; + } + + this._lastMarkdown = newMarkdown; + + // Buffers that handle flushing themselves (e.g. line buffer) + // don't update _renderedMarkdown here — _flushRender decides. + if (this._buffer.handlesFlush) { + this._pendingMarkdown = newMarkdown; + this._scheduleRender(); + return true; + } + + const renderable = this._buffer.getRenderable(newMarkdown, this._renderedMarkdown); + + if (renderable.length > this._renderedMarkdown.length) { + this._renderedMarkdown = renderable; + this._pendingMarkdown = renderable; + this._scheduleRender(); + } + + return true; + } + + // ---- rAF batching ---- + + private _scheduleRender(): void { + if (this._rafScheduled) { + return; + } + this._rafScheduled = true; + const win = getWindow(this._domNode); + this._rafHandle = win.requestAnimationFrame(() => { + this._rafScheduled = false; + this._rafHandle = undefined; + this._flushRender(); + }); + } + + private _flushRender(): void { + let markdown = this._pendingMarkdown; + this._pendingMarkdown = undefined; + + if (markdown === undefined || !this._renderCallback) { + return; + } + + // Let the buffer filter the flush (e.g. line buffer may skip). + if (this._buffer.filterFlush) { + const filtered = this._buffer.filterFlush(markdown); + if (filtered === undefined) { + // Buffer says skip — but if it needs another frame + // (e.g. typewriter still revealing words), re-schedule + // with the same pending content. + if (this._buffer.needsNextFrame) { + this._pendingMarkdown = markdown; + this._scheduleRender(); + } + return; + } + markdown = filtered; + } + + this._renderedMarkdown = markdown; + this._renderCallback(markdown); + this._animateNewChildren(); + + // If the buffer has more content to reveal, keep the rAF + // loop running even though no new tokens arrived. + if (this._buffer.needsNextFrame) { + this._pendingMarkdown = this._lastMarkdown; + this._scheduleRender(); + } + } + + // ---- animation ---- + + private _animateNewChildren(): void { + const children = this._domNode.children; + const currentCount = children.length; + + if (currentCount <= this._revealedChildCount) { + return; + } + + const now = Date.now(); + + if (this._animationStartTime !== 0 && (now - this._animationStartTime) >= ANIMATION_DURATION_MS) { + this._revealedChildCount = this._batchChildCount; + this._animationStartTime = 0; + this._batchChildCount = 0; + } + + if (currentCount <= this._revealedChildCount) { + return; + } + + if (this._animationStartTime === 0) { + this._animationStartTime = now; + } + + this._batchChildCount = currentCount; + const elapsed = now - this._animationStartTime; + + this._animation.animate(children, this._revealedChildCount, currentCount, elapsed); + } + + // ---- lifecycle ---- + + override dispose(): void { + if (this._rafHandle !== undefined) { + getWindow(this._domNode).cancelAnimationFrame(this._rafHandle); + this._rafHandle = undefined; + } + this._rafScheduled = false; + this._pendingMarkdown = undefined; + this._renderCallback = undefined; + this._buffer.dispose?.(); + super.dispose(); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/media/chatIncrementalRendering.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/media/chatIncrementalRendering.css new file mode 100644 index 0000000000000..2f6babf96568f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatIncrementalRendering/media/chatIncrementalRendering.css @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Smooth streaming block-level animations ---- */ + +/* + * Applied to newly inserted top-level children (paragraphs, code blocks, + * list items, etc.) as a post-processing decoration after they have been + * correctly rendered through the standard markdown pipeline. + * + * Each new child receives a staggered animation-delay so the batch + * reveals sequentially from top to bottom. Animating individual + * children (rather than wrapping them in a container div) avoids + * margin-collapsing changes that cause layout shifts during scrolling. + * + * The animation class and custom properties are removed after completion + * via an `animationend` listener in the renderer. + */ + +/* ---- fade (default) ---- */ +.chat-smooth-animate-fade { + opacity: 0; + animation: chatSmoothFade var(--chat-smooth-duration, 600ms) ease-out var(--chat-smooth-delay, 0ms) forwards; +} +@keyframes chatSmoothFade { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ---- rise ---- */ +.chat-smooth-animate-rise { + opacity: 0; + transform: translateY(4px); + animation: chatSmoothRise var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards; +} +@keyframes chatSmoothRise { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ---- blur ---- */ +.chat-smooth-animate-blur { + opacity: 0; + filter: blur(2px); + animation: chatSmoothBlur var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards; +} +@keyframes chatSmoothBlur { + from { opacity: 0; filter: blur(2px); } + to { opacity: 1; filter: blur(0); } +} + +/* ---- scale ---- */ +.chat-smooth-animate-scale { + opacity: 0; + transform: scale(0.95); + animation: chatSmoothScale var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards; +} +@keyframes chatSmoothScale { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} + +/* ---- slide ---- */ +.chat-smooth-animate-slide { + opacity: 0; + transform: translateX(-4px); + animation: chatSmoothSlide var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards; +} +@keyframes chatSmoothSlide { + from { opacity: 0; transform: translateX(-4px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ---- reveal ---- */ +.chat-smooth-animate-reveal { + -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 48px), transparent); + mask-image: linear-gradient(to bottom, black calc(100% - 48px), transparent); + -webkit-mask-size: 100% 0%; + mask-size: 100% 0%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + animation: chatSmoothReveal var(--chat-smooth-duration, 600ms) cubic-bezier(0.0, 0.0, 0.2, 1) var(--chat-smooth-delay, 0ms) forwards; +} +@keyframes chatSmoothReveal { + from { + -webkit-mask-size: 100% 0%; + mask-size: 100% 0%; + } + to { + -webkit-mask-size: 100% calc(100% + 48px); + mask-size: 100% calc(100% + 48px); + } +} + +/* ---- Respect prefers-reduced-motion ---- */ +@media (prefers-reduced-motion: reduce) { + .chat-smooth-animate-fade, + .chat-smooth-animate-rise, + .chat-smooth-animate-blur, + .chat-smooth-animate-scale, + .chat-smooth-animate-slide, + .chat-smooth-animate-reveal { + animation: none !important; + opacity: 1 !important; + transform: none !important; + filter: none !important; + -webkit-mask-image: none !important; + mask-image: none !important; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts index 521496533d410..a0e4141bf706c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -14,6 +14,7 @@ import { wrapTablesWithScrollable } from './chatMarkdownTableScrolling.js'; import { coalesce } from '../../../../../../base/common/arrays.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; @@ -59,6 +60,7 @@ import { IDisposableReference } from './chatCollections.js'; import { EditorPool } from './chatContentCodePools.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js'; +import { IncrementalDOMMorpher } from './chatIncrementalRendering/chatIncrementalRendering.js'; import './media/chatMarkdownPart.css'; const $ = dom.$; @@ -107,8 +109,11 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP private readonly mathLayoutParticipants = new Set<() => void>(); + /** Incremental rendering morpher — only created when the experiment is enabled. */ + private _incrementalMorpher: IncrementalDOMMorpher | undefined; + constructor( - private readonly markdown: IChatMarkdownContent, + private markdown: IChatMarkdownContent, context: IChatContentPartRenderContext, private readonly editorPool: EditorPool, fillInIncompleteTokens = false, @@ -142,6 +147,36 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP const enableMath = configurationService.getValue(ChatConfiguration.EnableMath); + // Initialize incremental rendering morpher when the experiment is enabled. + // Only create for actively streaming responses (!element.isComplete), + // not for completed responses loaded from history — even if + // fillInIncompleteTokens is true (e.g. canceled or incomplete responses). + const incrementalRenderingEnabled = configurationService.getValue(ChatConfiguration.IncrementalRendering); + if (incrementalRenderingEnabled && isResponseVM(element) && fillInIncompleteTokens && !element.isComplete) { + this._incrementalMorpher = this._register(instantiationService.createInstance(IncrementalDOMMorpher, this.domNode)); + this._incrementalMorpher.setRenderCallback((newMd) => { + // Temporarily swap this.markdown to the buffered content + // for doRenderMarkdown(), then restore it. The morpher may + // render a subset of the full markdown (word/paragraph + // buffering), but this.markdown must always reflect the + // latest full content from tryIncrementalUpdate so that + // hasSameContent() returns true and avoids unnecessary + // re-diffs on the next renderElement call. + const savedMarkdown = this.markdown; + const content = new MarkdownString(newMd, this.markdown.content); + content.baseUri = URI.revive(this.markdown.content.baseUri); + content.uris = this.markdown.content.uris; + this.markdown = { ...this.markdown, content }; + doRenderMarkdown(); + this.markdown = savedMarkdown; + // Notify the list that our height changed so it can + // update scroll position. The morpher renders via rAF, + // outside the normal renderElement flow, so the list + // won't pick this up without an explicit notification. + this._onDidChangeHeight.fire(); + }); + } + const renderStore = this._register(new MutableDisposable()); const doRenderMarkdown = () => { @@ -173,7 +208,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP breaks: true, }; - const result = store.add(renderer.render(markdown.content, { + const result = store.add(renderer.render(this.markdown.content, { sanitizerConfig: MarkedKatexSupport.getSanitizerOptions({ allowedTags: allowedChatMarkdownHtmlTags, allowedAttributes: allowedMarkdownHtmlAttributes, @@ -306,7 +341,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP } const markdownDecorationsRenderer = instantiationService.createInstance(ChatMarkdownDecorationsRenderer); - store.add(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element)); + store.add(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(this.markdown, result.element)); const layoutParticipants = new Lazy(() => { const observer = new ResizeObserver(() => this.mathLayoutParticipants.forEach(layout => layout())); @@ -339,6 +374,13 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP // Always render immediately doRenderMarkdown(); + // Seed the morpher *after* the initial render so it captures + // the correct markdown baseline. Pass `animateInitial: true` + // so the initial DOM children receive the entrance animation — + // this is important when a markdown part first appears (e.g. + // after thinking content) and already contains visible content. + this._incrementalMorpher?.seed(markdown.content.value, /* animateInitial */ true); + if (enableMath && !MarkedKatexSupport.getExtension(dom.getWindow(context.container))) { // KaTeX not yet loaded - load it and re-render when ready MarkedKatexSupport.loadExtension(dom.getWindow(context.container)) @@ -425,6 +467,45 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP return false; } + /** + * Attempts an incremental DOM update for smooth streaming instead of + * tearing down and rebuilding the entire markdown part. + * + * The morpher checks that the new content is a pure append, then + * schedules a rAF-batched re-render through the full markdown + * pipeline. Code blocks, tables, and all markdown features are + * rendered correctly because the update goes through the standard + * `doRenderMarkdown()` path. + * + * @param newMarkdown The new (appended) markdown content. + * @returns `true` if the incremental update succeeded and the caller + * should treat this part as unchanged. `false` if a full + * re-render is needed. + */ + tryIncrementalUpdate(newMarkdown: IChatMarkdownContent): boolean { + if (!this._incrementalMorpher) { + return false; + } + + const success = this._incrementalMorpher.tryMorph(newMarkdown.content.value); + + if (success) { + // Update the stored markdown so hasSameContent() returns true + // for subsequent diffs with the same content, allowing the + // progressive render to detect "caught up" and "complete" states. + this.markdown = newMarkdown; + } + + return success; + } + + /** + * Forward the stream's word-rate estimate to the morpher's buffer. + */ + updateStreamRate(rate: number, isComplete: boolean): void { + this._incrementalMorpher?.updateStreamRate(rate, isComplete); + } + layout(width: number): void { this.allRefs.forEach((ref, index) => { if (ref.object instanceof CodeBlockPart) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts index 710087171cae7..7b84ddadeff67 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts @@ -175,6 +175,8 @@ export class ChatModifiedFilesConfirmationSubPart extends AbstractToolConfirmati const listItems = data.modifiedFiles.map(file => { const resource = URI.revive(file.uri); const originalUri = file.originalUri ? URI.revive(file.originalUri) : undefined; + const modifiedContentUri = file.modifiedContentUri ? URI.revive(file.modifiedContentUri) : undefined; + const originalContentUri = file.originalContentUri ? URI.revive(file.originalContentUri) : undefined; return { kind: 'reference', reference: resource, @@ -187,7 +189,8 @@ export class ChatModifiedFilesConfirmationSubPart extends AbstractToolConfirmati added: file.insertions ?? 0, removed: file.deletions ?? 0, } : undefined, - originalUri, + originalUri: originalContentUri ?? originalUri, + modifiedUri: modifiedContentUri, status: undefined, } }; @@ -198,8 +201,9 @@ export class ChatModifiedFilesConfirmationSubPart extends AbstractToolConfirmati return; } - const modifiedUri = e.element.reference; - const originalUri = e.element.options?.originalUri; + const options = e.element.options; + const modifiedUri = options?.modifiedUri ?? e.element.reference; + const originalUri = options?.originalUri; if (originalUri) { await this.editorService.openEditor({ original: { resource: originalUri }, @@ -257,8 +261,8 @@ export class ChatModifiedFilesConfirmationSubPart extends AbstractToolConfirmati await this.commandService.executeCommand('_workbench.openMultiDiffEditor', { title: localize('modifiedFilesAllChangesTitle', 'All Changes'), resources: data.modifiedFiles.map(file => ({ - originalUri: file.originalUri ? URI.revive(file.originalUri) : undefined, - modifiedUri: URI.revive(file.uri), + originalUri: file.originalContentUri ? URI.revive(file.originalContentUri) : file.originalUri ? URI.revive(file.originalUri) : undefined, + modifiedUri: file.modifiedContentUri ? URI.revive(file.modifiedContentUri) : URI.revive(file.uri), })) }); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 19aa792fa2c2d..db8f2fb6f0ced 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -100,6 +100,7 @@ import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/ import { autorun, observableValue } from '../../../../../base/common/observable.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; import { HookType } from '../../common/promptSyntax/hookTypes.js'; @@ -235,6 +236,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.IncrementalRendering); if (isResponseVM(element) && index === this.delegate.getListLength() - 1 && (!element.isComplete || element.renderData)) { this.traceLayout('renderElement', `start progressive render, index=${index}`); - const timer = templateData.elementDisposables.add(new dom.WindowIntervalTimer()); - const runProgressiveRender = (initial?: boolean) => { - try { - if (this.doNextProgressiveRender(element, index, templateData, !!initial)) { + if (incrementalRendering && !element.renderData) { + // Incremental rendering: event-driven flow, no timer. + // renderElement is called each time the model changes, so + // this method runs on every content update. + this.logIncrementalRenderingTelemetry(); + this.doIncrementalRender(element, index, templateData); + } else { + const timer = templateData.elementDisposables.add(new dom.WindowIntervalTimer()); + const runProgressiveRender = (initial?: boolean) => { + try { + if (this.doNextProgressiveRender(element, index, templateData, !!initial)) { + timer.cancel(); + } + } catch (err) { + // Kill the timer if anything went wrong, avoid getting stuck in a nasty rendering loop. timer.cancel(); + this.logService.error(err); } - } catch (err) { - // Kill the timer if anything went wrong, avoid getting stuck in a nasty rendering loop. - timer.cancel(); - this.logService.error(err); - } - }; - timer.cancelAndSet(runProgressiveRender, 50, dom.getWindow(templateData.rowContainer)); - runProgressiveRender(true); + }; + timer.cancelAndSet(runProgressiveRender, 50, dom.getWindow(templateData.rowContainer)); + runProgressiveRender(true); + } } else { if (isResponseVM(element)) { + // When incremental rendering was active during this response, + // notify any active morpher that the stream is complete + // so it switches to a fast drain rate before we render. + if (incrementalRendering) { + const rate = this.getProgressiveRenderRate(element); + this._updateMorpherRate(templateData, rate, true); + } this.renderChatResponseBasic(element, index, templateData); } else if (isRequestVM(element)) { this.renderChatRequest(element, index, templateData); @@ -1404,6 +1425,90 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part === null); + if (!contentIsAlreadyRendered) { + this.renderChatContentDiff(partsToRender, contentForThisTurn.content, element, index, templateData); + } + } + + /** + * Propagate the stream's word-rate estimate to any active morpher's + * word buffer so it reveals content at the model's speed. + */ + private _updateMorpherRate(templateData: IChatListItemTemplate, rate: number, isComplete: boolean): void { + const renderedParts = templateData.renderedParts; + if (!renderedParts) { + return; + } + for (const part of renderedParts) { + if (part instanceof ChatMarkdownContentPart) { + part.updateStreamRate(rate, isComplete); + } + } + } + + private logIncrementalRenderingTelemetry(): void { + if (this._incrementalRenderingTelemetryLogged) { + return; + } + this._incrementalRenderingTelemetryLogged = true; + + type IncrementalRenderingSettingsEvent = { + animationStyle: string; + buffering: string; + }; + type IncrementalRenderingSettingsClassification = { + animationStyle: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The animation style selected for incremental rendering.' }; + buffering: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The buffering mode selected for incremental rendering.' }; + owner: 'pwang347'; + comment: 'Tracks which incremental rendering settings are in use.'; + }; + this.telemetryService.publicLog2('chatIncrementalRenderingSettings', { + animationStyle: this.configService.getValue(ChatConfiguration.IncrementalRenderingStyle) ?? 'none', + buffering: this.configService.getValue(ChatConfiguration.IncrementalRenderingBuffering) ?? 'word', + }); + } + /** * @returns true if progressive rendering should be considered complete- the element's data is fully rendered or the view is not visible */ @@ -1491,6 +1596,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.IncrementalRendering) + ) { + if (alreadyRenderedPart.tryIncrementalUpdate(partToRender)) { + renderedParts[contentIndex] = alreadyRenderedPart; + return; + } + } + alreadyRenderedPart.dispose(); // Replace old DOM from thinking wrapper to prevent accumulation @@ -1577,6 +1694,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.experimental.renderMarkdownImmediately') === true; + // When incremental rendering is enabled, skip word-counting for markdown. + // The morpher's own buffer + rAF loop is the sole rate limiter. + const incrementalRendering = this.configService.getValue(ChatConfiguration.IncrementalRendering) === true; + const renderableResponse = annotateSpecialMarkdownContent(element.response.value); this.traceLayout('getNextProgressiveRenderContent', `Want to render ${data.numWordsToRender} at ${data.rate} words/s, counting...`); @@ -1590,7 +1711,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { @@ -267,7 +269,7 @@ class SlashCommandCompletions extends Disposable { return true; }) .filter(c => c.userInvocable) - .filter(c => !c.when || widget.scopedContextKeyService.contextMatchesRules(c.when)); + .filter(c => matchesSessionType(c.sessionTypes, currentSessionType)); if (userInvocableCommands.length === 0) { return null; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 3f82f6a9098e8..47be09ec9af73 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -28,8 +28,9 @@ import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { matchesSessionType, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; +import { getChatSessionType } from '../../../common/model/chatUri.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -163,12 +164,14 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const getActionsForCustomAgentTarget = (currentTarget: Target): IActionWidgetDropdownAction[] => { const modes = chatModeService.getModes(); const currentMode = delegate.currentMode.get(); + const sessionResource = delegate.sessionResource(); + const currentSessionType = sessionResource ? getChatSessionType(sessionResource) : undefined; const filteredCustomModes = modes.custom.filter(mode => { const target = mode.target.get(); if (target !== currentTarget && target !== Target.Undefined) { return false; } - if (mode.when && !this.contextKeyService.contextMatchesRules(mode.when)) { + if (!matchesSessionType(mode.sessionTypes, currentSessionType)) { return false; } return true; @@ -195,12 +198,14 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const modes = chatModeService.getModes(); const currentMode = delegate.currentMode.get(); const agentMode = modes.builtin.find(mode => mode.id === ChatMode.Agent.id); + const sessionResource = delegate.sessionResource(); + const currentSessionType = sessionResource ? getChatSessionType(sessionResource) : undefined; const otherBuiltinModes = modes.builtin.filter(mode => { return mode.id !== ChatMode.Agent.id && shouldShowBuiltInMode(mode, assignments.get(), agentModeDisabledViaPolicy); }); const filteredCustomModes = modes.custom.filter(mode => { - if (mode.when && !this.contextKeyService.contextMatchesRules(mode.when)) { + if (!matchesSessionType(mode.sessionTypes, currentSessionType)) { return false; } if (isModeConsideredBuiltIn(mode, this._productService)) { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index 6a92395b8f212..ce81c152d2095 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -131,6 +131,7 @@ export class ChatModeService extends Disposable implements IChatModeService { target: cachedMode.target ?? Target.Undefined, visibility, agents: cachedMode.agents, + sessionTypes: cachedMode.sessionTypes, source: reviveChatModeSource(cachedMode.source) ?? { storage: PromptsStorage.local } }; const instance = new CustomChatMode(customChatMode); @@ -258,6 +259,7 @@ export interface IChatModeData { readonly target?: Target; readonly visibility?: ICustomAgentVisibility; readonly agents?: readonly string[]; + readonly sessionTypes?: readonly string[]; readonly infer?: boolean; // deprecated, only available in old cached data } @@ -279,6 +281,7 @@ export interface IChatMode { readonly target: IObservable; readonly visibility?: IObservable; readonly agents?: IObservable; + readonly sessionTypes?: readonly string[]; readonly when?: ContextKeyExpression; } @@ -312,7 +315,8 @@ function isCachedChatModeData(data: unknown): data is IChatModeData { (mode.source === undefined || isChatModeSourceData(mode.source)) && (mode.target === undefined || isTarget(mode.target)) && (mode.visibility === undefined || isCustomAgentVisibility(mode.visibility)) && - (mode.agents === undefined || Array.isArray(mode.agents)); + (mode.agents === undefined || Array.isArray(mode.agents)) && + (mode.sessionTypes === undefined || Array.isArray(mode.sessionTypes)); } export class CustomChatMode implements IChatMode { @@ -328,6 +332,7 @@ export class CustomChatMode implements IChatMode { private readonly _visibilityObservable: ISettableObservable; private readonly _agentsObservable: ISettableObservable; private _source: IAgentSource; + private _sessionTypes: readonly string[] | undefined; private _when: ContextKeyExpression | undefined; public readonly id: string; @@ -392,6 +397,10 @@ export class CustomChatMode implements IChatMode { return this._agentsObservable; } + get sessionTypes(): readonly string[] | undefined { + return this._sessionTypes; + } + get when(): ContextKeyExpression | undefined { return this._when; } @@ -414,6 +423,7 @@ export class CustomChatMode implements IChatMode { this._modeInstructions = observableValue('_modeInstructions', customChatMode.agentInstructions); this._uriObservable = observableValue('uri', customChatMode.uri); this._source = customChatMode.source; + this._sessionTypes = customChatMode.sessionTypes; this._when = customChatMode.when; } @@ -434,6 +444,7 @@ export class CustomChatMode implements IChatMode { this._modeInstructions.set(newData.agentInstructions, tx); this._uriObservable.set(newData.uri, tx); this._source = newData.source; + this._sessionTypes = newData.sessionTypes; this._when = newData.when; }); } @@ -453,7 +464,8 @@ export class CustomChatMode implements IChatMode { source: serializeChatModeSource(this._source), target: this.target.get(), visibility: this.visibility.get(), - agents: this.agents.get() + agents: this.agents.get(), + sessionTypes: this.sessionTypes, }; } } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 5f6e5ebabd71b..b61505cfba94d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -132,6 +132,8 @@ export interface IChatContentReference { status?: { description: string; kind: ChatResponseReferencePartStatusKind }; diffMeta?: { added: number; removed: number }; originalUri?: URI; + /** Overrides the reference URI when opening the modified side of a diff. */ + modifiedUri?: URI; isDeletion?: boolean; }; kind: 'reference'; @@ -981,6 +983,16 @@ export interface IChatModifiedFilesConfirmationData { readonly modifiedFiles: readonly { readonly uri: UriComponents; readonly originalUri?: UriComponents; + /** + * Optional URI to read the modified (after) content from for the diff + * view. When absent, {@link uri} is used as the modified side. + */ + readonly modifiedContentUri?: UriComponents; + /** + * Optional URI to read the original (before) content from for the diff + * view. When absent, {@link originalUri} is used as the original side. + */ + readonly originalContentUri?: UriComponents; readonly insertions?: number; readonly deletions?: number; readonly title?: string; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index c10495b475101..9c5c22961fb2a 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -685,10 +685,6 @@ export class ChatService extends Disposable implements IChatService { } } - if (providedSession.isCompleteObs?.get()) { - lastRequest?.response?.complete(); - } - // Set up progress streaming and cancellation for contributed sessions. // This handles both the initial in-flight response (from session load) // and any subsequent server-initiated turns (e.g. consumed queued messages). @@ -701,30 +697,27 @@ export class ChatService extends Disposable implements IChatService { return token.onCancellationRequested(() => { providedSession.interruptActiveResponseCallback?.().then(userConfirmedInterruption => { if (!userConfirmedInterruption) { - // User cancelled the interruption - const newCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined, undefined, undefined); - this._pendingRequests.set(model.sessionResource, newCancellationRequest); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); - cancellationListener.value = createCancellationListener(newCancellationRequest.cancellationTokenSource.token); + trackNewCancellableRequest(); } }); }); }; + const trackNewCancellableRequest = () => { + const cancellableRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined, undefined, undefined); + this._pendingRequests.set(model.sessionResource, cancellableRequest); + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); + cancellationListener.value = createCancellationListener(cancellableRequest.cancellationTokenSource.token); + }; + const ensureCancellationTracking = () => { if (!this._pendingRequests.has(model.sessionResource)) { - const cts = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined, undefined, undefined); - this._pendingRequests.set(model.sessionResource, cts); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); - cancellationListener.value = createCancellationListener(cts.cancellationTokenSource.token); + trackNewCancellableRequest(); } }; - if (lastRequest) { - const initialCancellationRequest = this.instantiationService.createInstance(CancellableRequest, new CancellationTokenSource(), undefined, undefined, undefined); - this._pendingRequests.set(model.sessionResource, initialCancellationRequest); - this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'add', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); - cancellationListener.value = createCancellationListener(initialCancellationRequest.cancellationTokenSource.token); + if (lastRequest && !providedSession.isCompleteObs?.get()) { + trackNewCancellableRequest(); } // Handle server-initiated requests (e.g. consumed queued messages). @@ -772,11 +765,16 @@ export class ChatService extends Disposable implements IChatService { // Handle completion if (isComplete && lastRequest) { - lastRequest.response?.complete(); + this._pendingRequests.deleteAndDispose(model.sessionResource); cancellationListener.clear(); + lastRequest.response?.complete(); } })); } else { + if (providedSession.isCompleteObs?.get()) { + lastRequest?.response?.complete(); + } + this.telemetryService.publicLog2(ChatPendingRequestChangeEventName, { action: 'notCancelable', source: 'remoteSession', chatSessionId: chatSessionResourceToId(model.sessionResource) }); if (lastRequest && model.editingSession) { // wait for timeline to load so that a 'changes' part is added when the response completes @@ -1120,7 +1118,7 @@ export class ChatService extends Disposable implements IChatService { // resolution can see them. We filter them back out below // to return only the entries that were newly added. const variableSet = new ChatRequestVariableSet(options?.attachedContext); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ctx.modeKind, ctx.enabledTools, ctx.enabledSubAgents); + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ctx.modeKind, ctx.enabledTools, ctx.enabledSubAgents, getChatSessionType(sessionResource)); await computer.collect(variableSet, token); // Return only the entries that were added by instruction collection const originalIds = new Set((options?.attachedContext ?? []).map(v => v.id)); @@ -1711,20 +1709,22 @@ export class ChatService extends Disposable implements IChatService { this.trace('cancelCurrentRequestForSession', `session: ${sessionResource}`); const pendingRequest = this._pendingRequests.get(sessionResource); if (!pendingRequest) { - const model = this._sessionModels.get(sessionResource); - const requestInProgress = model?.requestInProgress.get(); - const pendingRequestsCount = model?.getPendingRequests().length ?? 0; - const lastRequest = model?.lastRequest; - this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { - source: source ?? 'chatService', - reason: 'noPendingRequest', - requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', - pendingRequests: pendingRequestsCount, - sessionScheme: sessionResource.scheme, - lastRequestId: lastRequest?.id, - chatSessionId: chatSessionResourceToId(sessionResource), - }); - this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + if (source !== 'archive') { + const model = this._sessionModels.get(sessionResource); + const requestInProgress = model?.requestInProgress.get(); + const pendingRequestsCount = model?.getPendingRequests().length ?? 0; + const lastRequest = model?.lastRequest; + this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: source ?? 'chatService', + reason: 'noPendingRequest', + requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', + pendingRequests: pendingRequestsCount, + sessionScheme: sessionResource.scheme, + lastRequestId: lastRequest?.id, + chatSessionId: chatSessionResourceToId(sessionResource), + }); + this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + } return; } diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 6ab3453132950..49a79cf797d9e 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -70,6 +70,10 @@ export enum ChatConfiguration { ToolConfirmationCarousel = 'chat.tools.confirmationCarousel.enabled', DefaultNewSessionMode = 'chat.newSession.defaultMode', AgentHostClientTools = 'chat.agentHost.clientTools', + + IncrementalRendering = 'chat.experimental.incrementalRendering.enabled', + IncrementalRenderingStyle = 'chat.experimental.incrementalRendering.animationStyle', + IncrementalRenderingBuffering = 'chat.experimental.incrementalRendering.buffering', } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 5a6b648cd1bb3..bafbae1b9e09e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -12,7 +12,6 @@ import { basename, dirname } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -24,7 +23,7 @@ import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../t import { PromptsConfig } from './config/config.js'; import { isInClaudeAgentsFolder, isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; import { ParsedPromptFile } from './promptFileParser.js'; -import { AgentInstructionFileType, IAgentSkill, ICustomAgent, IInstructionFile, IPromptsService, newInstructionsCollectionEvent, newInstructionsCollectionDebugInfo, type InstructionsCollectionEvent, type InstructionsCollectionDebugInfo } from './service/promptsService.js'; +import { AgentInstructionFileType, IAgentSkill, ICustomAgent, IInstructionFile, IPromptsService, matchesSessionType, newInstructionsCollectionEvent, newInstructionsCollectionDebugInfo, type InstructionsCollectionEvent, type InstructionsCollectionDebugInfo } from './service/promptsService.js'; export type { InstructionsCollectionEvent, InstructionsCollectionDebugInfo } from './service/promptsService.js'; export { newInstructionsCollectionEvent, newInstructionsCollectionDebugInfo } from './service/promptsService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js'; @@ -66,11 +65,11 @@ export class ComputeAutomaticInstructions { private readonly _modeKind: ChatModeKind, private readonly _enabledTools: UserSelectedTools | undefined, private readonly _enabledSubagents: (readonly string[]) | undefined, + private readonly _currentSessionType: string, @IPromptsService private readonly _promptsService: IPromptsService, @ILogService public readonly _logService: ILogService, @ILabelService private readonly _labelService: ILabelService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IWorkspaceContextService private readonly _workspaceService: IWorkspaceContextService, @IFileService private readonly _fileService: IFileService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @@ -189,15 +188,16 @@ export class ComputeAutomaticInstructions { return; } + const currentSessionType = this._currentSessionType; + for (const instructionFile of instructionFiles) { if (token.isCancellationRequested) { return; } - const { uri, pattern, when } = instructionFile; + const { uri, pattern } = instructionFile; - // If a `when` clause is present, evaluate it; otherwise always include. - if (when && !this._contextKeyService.contextMatchesRules(when)) { + if (!matchesSessionType(instructionFile.sessionTypes, currentSessionType)) { continue; } @@ -342,6 +342,7 @@ export class ComputeAutomaticInstructions { private async _getCustomizationsIndex(instructionFiles: readonly IInstructionFile[], _existingVariables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, debugInfo: InstructionsCollectionDebugInfo, token: CancellationToken): Promise { const readTool = this._getTool('readFile'); const runSubagentTool = this._getTool(VSCodeToolReference.runSubagent); + const currentSessionType = this._currentSessionType; const remoteEnv = await this._remoteAgentService.getEnvironment(); const remoteOS = remoteEnv?.os; @@ -361,7 +362,7 @@ export class ComputeAutomaticInstructions { entries.push('Make sure to acquire the instructions before working with the codebase.'); let hasContent = false; for (const instruction of instructionFiles) { - if (instruction.when && !this._contextKeyService.contextMatchesRules(instruction.when)) { + if (!matchesSessionType(instruction.sessionTypes, currentSessionType)) { continue; } entries.push(''); @@ -396,7 +397,7 @@ export class ComputeAutomaticInstructions { const agentSkills = await this._promptsService.findAgentSkills(token); // Filter out skills with disableModelInvocation=true (they can only be triggered manually via /name) - // Also filter by `when` clause using the scoped context key service + // Also filter by session type in consumers outside the prompts service // Also filter out the troubleshoot skill when agent debug log file logging setting is disabled const isFileLoggingEnabled = this._configurationService.getValue(AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING); const modelInvocableSkills = agentSkills?.filter(skill => { @@ -404,8 +405,8 @@ export class ComputeAutomaticInstructions { debugInfo.debugDetails.push({ category: 'skipped', name: skill.name, uri: skill.uri, reason: localize('debugDetail.skillNotModelInvocable', 'model invocation disabled') }); return false; } - if (skill.when && !this._contextKeyService.contextMatchesRules(skill.when)) { - debugInfo.debugDetails.push({ category: 'skipped', name: skill.name, uri: skill.uri, reason: localize('debugDetail.skillWhenClause', "when clause not satisfied") }); + if (!matchesSessionType(skill.sessionTypes, currentSessionType)) { + debugInfo.debugDetails.push({ category: 'skipped', name: skill.name, uri: skill.uri, reason: localize('debugDetail.skillSessionType', 'session type not matched') }); return false; } if (!isFileLoggingEnabled && skill.uri.path.includes(TROUBLESHOOT_SKILL_PATH)) { @@ -459,10 +460,10 @@ export class ComputeAutomaticInstructions { const canUseAgent = (() => { if (!this._enabledSubagents || this._enabledSubagents.includes('*')) { - return (agent: ICustomAgent) => agent.visibility.agentInvocable && (!agent.when || this._contextKeyService.contextMatchesRules(agent.when)); + return (agent: ICustomAgent) => agent.visibility.agentInvocable && matchesSessionType(agent.sessionTypes, currentSessionType); } else { const subagents = this._enabledSubagents; - return (agent: ICustomAgent) => subagents.includes(agent.name) && (!agent.when || this._contextKeyService.contextMatchesRules(agent.when)); + return (agent: ICustomAgent) => subagents.includes(agent.name) && matchesSessionType(agent.sessionTypes, currentSessionType); } })(); const agents = await this._promptsService.getCustomAgents(token); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index d8a533d5fbc0c..f4a97b9a8b716 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -87,12 +87,23 @@ export interface IPromptFileResource { * Optional externally provided prompt command description. */ readonly description?: string; + /** + * Optional condition that must evaluate to true for this resource to be offered. + */ + readonly when?: string; /** * Optional session types that describe when this resource should be offered. */ readonly sessionTypes?: readonly string[]; } +/** + * Returns whether a customization can be used in the provided chat session type. + */ +export function matchesSessionType(sessionTypes: readonly string[] | undefined, currentSessionType: string | undefined): boolean { + return sessionTypes === undefined || currentSessionType === undefined || sessionTypes.includes(currentSessionType); +} + /** * Provides prompt services. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 3a75fb8f6b9f8..645989eb30acd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -81,6 +81,13 @@ export class SkillNameMismatchError extends Error { } } +type PromptFileProviderEntry = { + extension: IExtensionDescription; + type: PromptsType; + onDidChangePromptFiles?: Event; + providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise; +}; + /** * Provides prompt services. */ @@ -148,10 +155,11 @@ export class PromptsService extends Disposable implements IPromptsService { }; /** - * Context keys referenced by contributed file `when` clauses. + * Context keys referenced by contributed and provider-supplied `when` clauses. */ private readonly _contributedWhenKeys = new Set(); private readonly _contributedWhenClauses = new Map(); + private readonly _providerWhenClauses = new Map(); private readonly _onDidContributedWhenChange = this._register(new Emitter()); private readonly _onDidChangeInstructions = this._register(new Emitter()); private readonly _onDidPluginPromptFilesChange = this._register(new Emitter()); @@ -391,12 +399,7 @@ export class PromptsService extends Disposable implements IPromptsService { * Registry of prompt file provider instances (custom agents, instructions, prompt files). * Extensions can register providers via the proposed API. */ - private readonly promptFileProviders: Array<{ - extension: IExtensionDescription; - type: PromptsType; - onDidChangePromptFiles?: Event; - providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise; - }> = []; + private readonly promptFileProviders: PromptFileProviderEntry[] = []; /** * Registers a prompt file provider (CustomAgentProvider, InstructionsProvider, or PromptFileProvider). @@ -428,6 +431,8 @@ export class PromptsService extends Disposable implements IPromptsService { const index = this.promptFileProviders.findIndex((p) => p === providerEntry); if (index >= 0) { this.promptFileProviders.splice(index, 1); + this._providerWhenClauses.delete(providerEntry); + this._updateContributedWhenKeys(); this.invalidatePromptFileCache(type); } } @@ -472,6 +477,8 @@ export class PromptsService extends Disposable implements IPromptsService { for (const providerEntry of providers) { try { const files = await providerEntry.providePromptFiles({}, token); + this._providerWhenClauses.set(providerEntry, files?.flatMap(file => file.when ? [file.when] : []) ?? []); + this._updateContributedWhenKeys(); if (!files || token.isCancellationRequested) { continue; } @@ -486,6 +493,7 @@ export class PromptsService extends Disposable implements IPromptsService { source: PromptFileSource.ExtensionAPI, name: file.name, description: file.description, + when: file.when, sessionTypes: file.sessionTypes, } satisfies IExtensionPromptPath); } @@ -504,48 +512,50 @@ export class PromptsService extends Disposable implements IPromptsService { public async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { + let promptPaths: readonly IPromptPath[]; switch (storage) { case PromptsStorage.extension: - return this.getExtensionPromptFiles(type, token); + promptPaths = await this.getExtensionPromptFiles(type, token); + break; case PromptsStorage.local: - return this.fileLocator.listFiles(type, PromptsStorage.local, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.local, type } satisfies ILocalPromptPath))); + promptPaths = await this.fileLocator.listFiles(type, PromptsStorage.local, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.local, type } satisfies ILocalPromptPath))); + break; case PromptsStorage.user: - return this.fileLocator.listFiles(type, PromptsStorage.user, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.user, type } satisfies IUserPromptPath))); + promptPaths = await this.fileLocator.listFiles(type, PromptsStorage.user, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.user, type } satisfies IUserPromptPath))); + break; case PromptsStorage.plugin: - return this._pluginPromptFilesByType.get(type) ?? []; + promptPaths = this._pluginPromptFilesByType.get(type) ?? []; + break; default: throw new Error(`[listPromptFilesForStorage] Unsupported prompt storage type: ${storage}`); } + + return promptPaths; } private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { await this.extensionService.whenInstalledExtensionsRegistered(); const settledResults = await Promise.allSettled(this.contributedFiles[type].values()); - // Note: `when` clauses are intentionally NOT evaluated here (global context). - // They are propagated into the model types (IAgentSkill, IChatPromptSlashCommand, - // ICustomAgent, IInstructionFile) and evaluated later at session-scoped time: - // - slash commands: per-widget in chatInputCompletions.ts via `c.when` - // - skills: in ComputeAutomaticInstructions using its scoped IContextKeyService - // - instructions: in ComputeAutomaticInstructions using its scoped IContextKeyService - // - agents: in modePickerActionItem.ts and ComputeAutomaticInstructions const contributedFiles = settledResults .filter((result): result is PromiseFulfilledResult => result.status === 'fulfilled') - .map(result => result.value) - .filter(file => { - if (file.when && !ContextKeyExpr.deserialize(file.when)) { - this.logger.warn(`[getExtensionPromptFiles] Ignoring contributed prompt file with invalid when clause: ${file.when}`); - return false; - } - return true; - }); + .map(result => result.value); const activationEvent = this.getProviderActivationEvent(type); - if (!activationEvent) { - // No provider activation event for this type (e.g., hooks) - return contributedFiles; - } - const providerFiles = await this.listFromProviders(type, activationEvent, token); - return [...contributedFiles, ...providerFiles]; + const providerFiles = activationEvent ? await this.listFromProviders(type, activationEvent, token) : []; + + return [...contributedFiles, ...providerFiles].filter(file => { + if (!file.when) { + return true; + } + + const when = ContextKeyExpr.deserialize(file.when); + if (!when) { + this.logger.warn(`[getExtensionPromptFiles] Ignoring contributed prompt file with invalid when clause: ${file.when}`); + return false; + } + + return this.contextKeyService.contextMatchesRules(when); + }); } private getProviderActivationEvent(type: PromptsType): string | undefined { @@ -959,6 +969,14 @@ export class PromptsService extends Disposable implements IPromptsService { this._contributedWhenKeys.add(key); } } + for (const whenClauses of this._providerWhenClauses.values()) { + for (const whenClause of whenClauses) { + const expr = ContextKeyExpr.deserialize(whenClause); + for (const key of expr?.keys() ?? []) { + this._contributedWhenKeys.add(key); + } + } + } } getPromptLocationLabel(promptPath: IPromptPath): string { diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 963689f55ba7f..632ebeba1c557 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -21,6 +21,7 @@ import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, GeneralPurposeAgentName } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; +import { getChatSessionType } from '../../model/chatUri.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js'; import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ChatRequestHooks, mergeHooks } from '../../promptSyntax/hookSchema.js'; @@ -301,7 +302,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined); + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined, getChatSessionType(invocation.context.sessionResource)); await computer.collect(variableSet, token); // Collect hooks from hook .json files 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 d3c22b32b2543..18bb78406a2b4 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 @@ -324,6 +324,10 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }); instantiationService.stub(IAgentHostTerminalService, { reviveTerminal: async () => undefined!, + createTerminalForEntry: async () => undefined, + profiles: observableValue('test', []), + getProfileForConnection: () => undefined, + registerEntry: () => ({ dispose() { } }), }); instantiationService.stub(IAgentHostSessionWorkingDirectoryResolver, { registerResolver: () => toDisposable(() => { }), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 23669e3a08043..98d568bb1f33a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -408,6 +408,10 @@ suite('AgentHostClientTools', () => { }); instantiationService.stub(IAgentHostTerminalService, { reviveTerminal: async () => undefined!, + createTerminalForEntry: async () => undefined, + profiles: observableValue('test', []), + getProfileForConnection: () => undefined, + registerEntry: () => ({ dispose() { } }), }); instantiationService.stub(IAgentHostSessionWorkingDirectoryResolver, { registerResolver: () => toDisposable(() => { }), diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 8f427d7f4f4ff..620d37314458a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type IActiveTurn, type ICompletedToolCall, type IToolCallRunningState, type ITurn, type IToolCallResponsePart, ToolCallCancellationReason } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocationSerialized, type IChatMarkdownContent } from '../../../common/chatService/chatService.js'; import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; -import { turnsToHistory, activeTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- @@ -52,11 +52,23 @@ function createTurn(overrides?: Partial): ITurn { } function toolCallStateToInvocation(tc: Parameters[0], subAgentInvocationId?: string) { - return rawToolCallStateToInvocation(tc, subAgentInvocationId, URI.file('/')); + return rawToolCallStateToInvocation(tc, subAgentInvocationId, URI.file('/'), undefined); } function finalizeToolInvocation(invocation: Parameters[0], tc: Parameters[1]) { - return rawFinalizeToolInvocation(invocation, tc, URI.file('/')); + return rawFinalizeToolInvocation(invocation, tc, URI.file('/'), undefined); +} + +function turnsToHistory(backendSession: Parameters[0], turns: Parameters[1], participantId: Parameters[2], modelId?: Parameters[4]) { + return rawTurnsToHistory(backendSession, turns, participantId, undefined, modelId); +} + +function activeTurnToProgress(sessionResource: Parameters[0], activeTurn: Parameters[1], connectionAuthority?: Parameters[2]) { + return rawActiveTurnToProgress(sessionResource, activeTurn, connectionAuthority); +} + +function updateRunningToolSpecificData(existing: Parameters[0], tc: Parameters[1]) { + return rawUpdateRunningToolSpecificData(existing, tc, undefined); } // ---- Tests ------------------------------------------------------------------ @@ -217,6 +229,70 @@ suite('stateToProgressAdapter', () => { assert.strictEqual((response.parts[0] as IChatMarkdownContent).content.value, 'Hello world'); }); + test('markdown links in response content are rewritten through the agent host scheme', () => { + const turn = createTurn({ + responseParts: [{ + kind: ResponsePartKind.Markdown, + id: 'md-links', + content: 'See [local](file:///a/b.ts), [content](agenthost-content:///s/x), [external](https://example.com) and [rel](./foo.md).', + }], + }); + + const history = rawTurnsToHistory(URI.file('/'), [turn], 'p', 'my-host'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const part = response.parts[0] as IChatMarkdownContent; + assert.deepStrictEqual(part.content.value, + 'See [](vscode-agent-host://my-host/file/-/a/b.ts), ' + + '[](vscode-agent-host://my-host/agenthost-content/-/s/x), ' + + '[external](https://example.com) and ' + + '[rel](./foo.md).' + ); + }); + + test('markdown link syntax inside fenced code blocks is preserved verbatim', () => { + const input = [ + 'Use [real](file:///a.ts) directly.', + '', + '```md', + '[fake](file:///b.ts)', + '```', + '', + 'And then [another](file:///c.ts).', + ].join('\n'); + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-code', content: input }], + }); + + const history = rawTurnsToHistory(URI.file('/'), [turn], 'p', 'my-host'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const value = (response.parts[0] as IChatMarkdownContent).content.value; + assert.ok(value.includes('[](vscode-agent-host://my-host/file/-/a.ts)')); + assert.ok(value.includes('[](vscode-agent-host://my-host/file/-/c.ts)')); + // The link inside the fenced code block must NOT be rewritten. + assert.ok(value.includes('[fake](file:///b.ts)')); + assert.ok(!value.includes('[fake](vscode-agent-host')); + }); + + test('markdown link syntax inside inline code spans is preserved verbatim', () => { + const input = 'Real [one](file:///a.ts) and literal `[two](file:///b.ts)` here.'; + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'md-codespan', content: input }], + }); + + const history = rawTurnsToHistory(URI.file('/'), [turn], 'p', 'my-host'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const value = (response.parts[0] as IChatMarkdownContent).content.value; + assert.strictEqual(value, + 'Real [](vscode-agent-host://my-host/file/-/a.ts) and literal `[two](file:///b.ts)` here.' + ); + }); + test('error turn produces error message in history', () => { const turn = createTurn({ state: TurnState.Error, @@ -509,14 +585,14 @@ suite('stateToProgressAdapter', () => { } test('empty active turn produces empty progress', () => { - const result = activeTurnToProgress(URI.file('/'), createActiveTurnState()); + const result = activeTurnToProgress(URI.file('/'), createActiveTurnState(), undefined); assert.deepStrictEqual(result, []); }); test('produces markdown content for streamed text', () => { const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ { kind: ResponsePartKind.Markdown, id: 'md-1', content: 'Hello world' }, - ])); + ]), undefined); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].kind, 'markdownContent'); assert.strictEqual((result[0] as IChatMarkdownContent).content.value, 'Hello world'); @@ -525,7 +601,7 @@ suite('stateToProgressAdapter', () => { test('produces thinking progress for reasoning', () => { const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ { kind: ResponsePartKind.Reasoning, id: 'r-1', content: 'Let me think about this...' }, - ])); + ]), undefined); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].kind, 'thinking'); }); @@ -534,7 +610,7 @@ suite('stateToProgressAdapter', () => { const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ { kind: ResponsePartKind.Reasoning, id: 'r-1', content: 'Hmm...' }, { kind: ResponsePartKind.Markdown, id: 'md-1', content: 'Result text' }, - ])); + ]), undefined); assert.strictEqual(result.length, 2); assert.strictEqual(result[0].kind, 'thinking'); assert.strictEqual(result[1].kind, 'markdownContent'); @@ -555,7 +631,7 @@ suite('stateToProgressAdapter', () => { pastTenseMessage: 'Ran test tool', } as IToolCallResponsePart['toolCall'], }, - ])); + ]), undefined); assert.strictEqual(result.length, 1); assert.strictEqual(result[0].kind, 'toolInvocationSerialized'); }); @@ -569,7 +645,7 @@ suite('stateToProgressAdapter', () => { status: ToolCallStatus.Running, }), }, - ])); + ]), undefined); assert.strictEqual(result.length, 1); // Live ChatToolInvocation - check it has the right toolCallId const invocation = result[0] as { toolCallId?: string; kind?: string }; @@ -590,7 +666,7 @@ suite('stateToProgressAdapter', () => { toolInput: 'echo hello', }, }, - ])); + ]), undefined); assert.strictEqual(result.length, 1); // PendingConfirmation tools have input-style specific data (no terminal content yet) const invocation = result[0] as { toolSpecificData?: { kind: string } }; @@ -620,7 +696,7 @@ suite('stateToProgressAdapter', () => { confirmationTitle: 'Confirm', }, }, - ])); + ]), undefined); // reasoning + text + tool call + pending confirmation = 4 items assert.strictEqual(result.length, 4); assert.strictEqual(result[0].kind, 'thinking'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatIncrementalRendering.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatIncrementalRendering.test.ts new file mode 100644 index 0000000000000..320ee9b6b9102 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatIncrementalRendering.test.ts @@ -0,0 +1,445 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { BlockAnimation, ANIMATION_DURATION_MS } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/animations/blockAnimations.js'; +import { lastBlockBoundary } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/buffers/paragraphBuffer.js'; +import { WordBuffer } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/buffers/wordBuffer.js'; +import { IncrementalDOMMorpher } from '../../../../browser/widget/chatContentParts/chatIncrementalRendering/chatIncrementalRendering.js'; +import { ChatConfiguration } from '../../../../common/constants.js'; + +suite('lastBlockBoundary', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns -1 for empty string', () => { + assert.strictEqual(lastBlockBoundary(''), -1); + }); + + test('returns -1 for text without any block boundary', () => { + assert.strictEqual(lastBlockBoundary('hello world'), -1); + }); + + test('returns -1 for single newline', () => { + assert.strictEqual(lastBlockBoundary('hello\nworld'), -1); + }); + + test('finds a single block boundary', () => { + const text = 'hello\n\nworld'; + assert.strictEqual(lastBlockBoundary(text), 5); + }); + + test('finds the last block boundary among multiple', () => { + const text = 'a\n\nb\n\nc'; + assert.strictEqual(lastBlockBoundary(text), 4); + }); + + test('ignores block boundaries inside a fenced code block', () => { + const text = '```\ncode\n\nmore code\n```'; + assert.strictEqual(lastBlockBoundary(text), -1); + }); + + test('finds boundary after closing a code fence', () => { + const text = '```\ncode\n```\n\nafter fence'; + assert.strictEqual(lastBlockBoundary(text), 12); + }); + + test('ignores boundary inside fence but finds one outside', () => { + const text = 'before\n\n```\ninside\n\nfence\n```\n\nafter'; + // First \n\n at index 6 (before fence), inside fence at ~18, after fence at ~28 + const result = lastBlockBoundary(text); + // The last valid boundary should be the one after the closing ``` + assert.ok(result > 6, `Expected boundary after fence close, got ${result}`); + }); + + test('handles code fence at the very start of the string', () => { + const text = '```\ncode\n```\n\ntext'; + assert.strictEqual(lastBlockBoundary(text), 12); + }); + + test('handles unclosed code fence (all subsequent boundaries ignored)', () => { + const text = '```\ncode\n\nmore\n\nstill inside'; + assert.strictEqual(lastBlockBoundary(text), -1); + }); + + test('handles multiple code fences', () => { + const text = '```\nfirst\n```\n\nbetween\n\n```\nsecond\n```\n\nend'; + const result = lastBlockBoundary(text); + // Last valid \n\n is after the second closing fence + assert.ok(result > 20, `Expected last boundary near end, got ${result}`); + }); + + test('handles triple backticks mid-line (not a fence)', () => { + // Triple backticks must be at the start of a line to count as a fence + const text = 'text ``` not a fence\n\nafter'; + assert.strictEqual(lastBlockBoundary(text), 20); + }); + + test('ignores block boundaries inside a tilde-fenced code block', () => { + const text = '~~~\ncode\n\nmore code\n~~~'; + assert.strictEqual(lastBlockBoundary(text), -1); + }); + + test('finds boundary after closing a tilde fence', () => { + const text = '~~~\ncode\n~~~\n\nafter fence'; + assert.strictEqual(lastBlockBoundary(text), 12); + }); + + test('handles unclosed tilde fence', () => { + const text = '~~~\ncode\n\nmore\n\nstill inside'; + assert.strictEqual(lastBlockBoundary(text), -1); + }); + + test('handles mixed backtick and tilde fences', () => { + const text = '~~~\ntilde code\n\ninside tilde\n~~~\n\n```\nbacktick code\n\ninside backtick\n```\n\nafter both'; + const result = lastBlockBoundary(text); + // The last valid boundary should be after the closing ``` + assert.ok(result > 40, `Expected boundary after both fences, got ${result}`); + }); +}); + +suite('IncrementalDOMMorpher', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let disposables: DisposableStore; + let instantiationService: ReturnType; + let configService: TestConfigurationService; + + setup(() => { + disposables = store.add(new DisposableStore()); + instantiationService = workbenchInstantiationService(undefined, disposables); + + configService = new TestConfigurationService(); + configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, 'fade'); + instantiationService.stub(IConfigurationService, configService); + }); + + teardown(() => { + disposables.dispose(); + }); + + function createMorpher(domNode?: HTMLElement): IncrementalDOMMorpher { + const node = domNode ?? mainWindow.document.createElement('div'); + return store.add(instantiationService.createInstance(IncrementalDOMMorpher, node)); + } + + suite('tryMorph', () => { + + test('returns false for non-append edit', () => { + const morpher = createMorpher(); + morpher.seed('hello'); + assert.strictEqual(morpher.tryMorph('goodbye'), false); + }); + + test('returns true when content is identical (no-op)', () => { + const morpher = createMorpher(); + morpher.seed('hello'); + assert.strictEqual(morpher.tryMorph('hello'), true); + }); + + test('returns true for appended content', () => { + const morpher = createMorpher(); + morpher.seed('hello'); + assert.strictEqual(morpher.tryMorph('hello world'), true); + }); + + test('returns false when prefix changes', () => { + const morpher = createMorpher(); + morpher.seed('hello world'); + assert.strictEqual(morpher.tryMorph('Hello world!'), false); + }); + + test('successive appends all succeed', () => { + const morpher = createMorpher(); + morpher.seed('a'); + assert.strictEqual(morpher.tryMorph('ab'), true); + assert.strictEqual(morpher.tryMorph('abc'), true); + assert.strictEqual(morpher.tryMorph('abcd'), true); + }); + + test('fails after a non-append edit even if previous appends succeeded', () => { + const morpher = createMorpher(); + morpher.seed('hello'); + assert.strictEqual(morpher.tryMorph('hello world'), true); + // Now a rewrite of earlier content + assert.strictEqual(morpher.tryMorph('hi world'), false); + }); + + test('invokes render callback on rAF with block-boundary content', () => { + const rendered: string[] = []; + const morpher = createMorpher(); + morpher.setRenderCallback(md => rendered.push(md)); + morpher.seed(''); + + // Append content with a block boundary + morpher.tryMorph('paragraph one\n\nparagraph two'); + // The callback fires asynchronously via rAF, not synchronously + assert.strictEqual(rendered.length, 0, 'Should not render synchronously'); + }); + + test('returns true for content without block boundary (buffered)', () => { + const morpher = createMorpher(); + morpher.seed(''); + // No \n\n — content is buffered + assert.strictEqual(morpher.tryMorph('partial paragraph'), true); + }); + }); + + suite('seed', () => { + + test('sets baseline markdown', () => { + const morpher = createMorpher(); + morpher.seed('initial content'); + // After seeding, tryMorph with same content is a no-op + assert.strictEqual(morpher.tryMorph('initial content'), true); + // And appending works + assert.strictEqual(morpher.tryMorph('initial content more'), true); + }); + + test('with animateInitial=false uses existing child count as watermark', () => { + const domNode = mainWindow.document.createElement('div'); + domNode.appendChild(mainWindow.document.createElement('p')); + domNode.appendChild(mainWindow.document.createElement('p')); + const morpher = createMorpher(domNode); + + morpher.seed('some content', false); + // No animation classes should be applied since all children are "revealed" + for (const child of Array.from(domNode.children)) { + assert.strictEqual( + (child as HTMLElement).classList.contains('chat-smooth-animate-fade'), + false, + 'Existing children should not be animated when animateInitial is false' + ); + } + }); + + test('with animateInitial=true animates existing children', () => { + const domNode = mainWindow.document.createElement('div'); + domNode.appendChild(mainWindow.document.createElement('p')); + domNode.appendChild(mainWindow.document.createElement('p')); + const morpher = createMorpher(domNode); + + morpher.seed('some content', true); + // Children should have the animation class + for (const child of Array.from(domNode.children)) { + assert.strictEqual( + (child as HTMLElement).classList.contains('chat-smooth-animate-fade'), + true, + 'Existing children should be animated when animateInitial is true' + ); + } + }); + }); + + suite('animation style', () => { + + test('defaults to fade for invalid config value', () => { + configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, 'invalid-style'); + const domNode = mainWindow.document.createElement('div'); + domNode.appendChild(mainWindow.document.createElement('p')); + const morpher = createMorpher(domNode); + morpher.seed('content', true); + + const child = domNode.children[0] as HTMLElement; + assert.strictEqual(child.classList.contains('chat-smooth-animate-fade'), true, 'Should fall back to fade'); + }); + + test('uses configured animation style', () => { + configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, 'rise'); + const domNode = mainWindow.document.createElement('div'); + domNode.appendChild(mainWindow.document.createElement('p')); + const morpher = createMorpher(domNode); + morpher.seed('content', true); + + const child = domNode.children[0] as HTMLElement; + assert.strictEqual(child.classList.contains('chat-smooth-animate-rise'), true, 'Should use rise style'); + }); + + for (const style of ['fade', 'rise', 'blur', 'scale', 'slide'] as const) { + test(`applies ${style} animation class`, () => { + configService.setUserConfiguration(ChatConfiguration.IncrementalRenderingStyle, style); + const domNode = mainWindow.document.createElement('div'); + domNode.appendChild(mainWindow.document.createElement('p')); + const morpher = createMorpher(domNode); + morpher.seed('content', true); + + const child = domNode.children[0] as HTMLElement; + assert.strictEqual( + child.classList.contains(`chat-smooth-animate-${style}`), + true, + `Should have chat-smooth-animate-${style} class` + ); + }); + } + }); + + suite('dispose', () => { + + test('clears pending state on dispose', () => { + const morpher = createMorpher(); + morpher.seed(''); + morpher.setRenderCallback(() => { }); + morpher.tryMorph('hello\n\nworld'); + // Dispose before rAF fires + morpher.dispose(); + // No error should occur — rAF is cancelled + }); + }); +}); + +suite('BlockAnimation', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('applies animation class and custom properties to new children', () => { + const anim = new BlockAnimation('fade'); + const container = mainWindow.document.createElement('div'); + const child = container.appendChild(mainWindow.document.createElement('p')); + + anim.animate(container.children, 0, 1, 0); + + assert.strictEqual(child.classList.contains('chat-smooth-animate-fade'), true); + assert.strictEqual(child.style.getPropertyValue('--chat-smooth-duration'), `${ANIMATION_DURATION_MS}ms`); + assert.ok(child.style.getPropertyValue('--chat-smooth-delay') !== ''); + }); + + test('does not strip animation class on bubbled animationend from nested element', () => { + const anim = new BlockAnimation('rise'); + const container = mainWindow.document.createElement('div'); + const parent = container.appendChild(mainWindow.document.createElement('div')); + const nested = parent.appendChild(mainWindow.document.createElement('span')); + + anim.animate(container.children, 0, 1, 0); + assert.strictEqual(parent.classList.contains('chat-smooth-animate-rise'), true); + + // Simulate animationend bubbling from nested child + const bubbledEvent = new AnimationEvent('animationend', { bubbles: true }); + nested.dispatchEvent(bubbledEvent); + + // Parent should still have the animation class + assert.strictEqual( + parent.classList.contains('chat-smooth-animate-rise'), + true, + 'Animation class should not be removed by bubbled event' + ); + assert.strictEqual( + parent.style.getPropertyValue('--chat-smooth-duration'), + `${ANIMATION_DURATION_MS}ms`, + 'Custom properties should not be removed by bubbled event' + ); + }); + + test('strips animation class on direct animationend from the animated element', () => { + const anim = new BlockAnimation('blur'); + const container = mainWindow.document.createElement('div'); + const child = container.appendChild(mainWindow.document.createElement('p')); + + anim.animate(container.children, 0, 1, 0); + assert.strictEqual(child.classList.contains('chat-smooth-animate-blur'), true); + + // Simulate direct animationend on the child itself + const directEvent = new AnimationEvent('animationend', { bubbles: true }); + child.dispatchEvent(directEvent); + + assert.strictEqual( + child.classList.contains('chat-smooth-animate-blur'), + false, + 'Animation class should be removed after direct animationend' + ); + assert.strictEqual( + child.style.getPropertyValue('--chat-smooth-duration'), + '', + 'Custom property should be removed after direct animationend' + ); + }); + + test('staggers delay across multiple new children', () => { + const anim = new BlockAnimation('fade'); + const container = mainWindow.document.createElement('div'); + container.appendChild(mainWindow.document.createElement('p')); + container.appendChild(mainWindow.document.createElement('p')); + container.appendChild(mainWindow.document.createElement('p')); + + anim.animate(container.children, 0, 3, 0); + + const delays = Array.from(container.children).map( + c => parseInt((c as HTMLElement).style.getPropertyValue('--chat-smooth-delay')) + ); + // Each successive child should have a larger delay + assert.ok(delays[1] > delays[0], `Second delay ${delays[1]} should be greater than first ${delays[0]}`); + assert.ok(delays[2] > delays[1], `Third delay ${delays[2]} should be greater than second ${delays[1]}`); + }); +}); + +suite('WordBuffer', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('setRate with isComplete uses at least MIN_RATE_AFTER_COMPLETE', () => { + const buffer = new WordBuffer(); + + // Setting a low rate with isComplete should floor to 80 + buffer.setRate(10, true); + // Verify by checking filterFlush behavior: with rate=80, + // after enough elapsed time, words should be revealed faster + // than at rate=10. + const md = 'word1 word2 word3 word4 word5 word6 word7 word8 word9 word10'; + const result1 = buffer.filterFlush(md); + // First call reveals 1 word + assert.ok(result1 !== undefined, 'First flush should reveal content'); + }); + + test('setRate with undefined rate and isComplete defaults to MIN_RATE_AFTER_COMPLETE', () => { + const buffer = new WordBuffer(); + buffer.setRate(undefined, true); + + const md = 'word1 word2 word3'; + const result = buffer.filterFlush(md); + assert.ok(result !== undefined, 'Should reveal content with default complete rate'); + }); + + test('setRate during streaming clamps between MIN_RATE and MAX_RATE', () => { + const buffer = new WordBuffer(); + + // Rate below MIN_RATE should be clamped up + buffer.setRate(1, false); + const md = 'word1 word2 word3'; + const result = buffer.filterFlush(md); + assert.ok(result !== undefined, 'Should reveal content even with low rate (clamped to MIN_RATE)'); + }); + + test('setRate with undefined rate during streaming defaults to DEFAULT_RATE', () => { + const buffer = new WordBuffer(); + buffer.setRate(undefined, false); + + const md = 'word1 word2'; + const result = buffer.filterFlush(md); + assert.ok(result !== undefined, 'Should reveal content with default streaming rate'); + }); + + test('needsNextFrame is true when words remain unrevealed', () => { + const buffer = new WordBuffer(); + buffer.setRate(1, false); + + // First flush reveals 1 word, but there are more + buffer.filterFlush('word1 word2 word3 word4 word5'); + assert.strictEqual(buffer.needsNextFrame, true, 'Should need another frame when words remain'); + }); + + test('needsNextFrame is false when all words are revealed', () => { + const buffer = new WordBuffer(); + buffer.setRate(2000, false); + + // With a very high rate and single word, all content is revealed + buffer.filterFlush('hello'); + assert.strictEqual(buffer.needsNextFrame, false, 'Should not need another frame when all words shown'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 112be2cd908c8..8302030f1340a 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -42,7 +42,7 @@ import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; import { IChatDebugService } from '../../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; -import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatProgress, IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; @@ -55,7 +55,7 @@ import { MockChatVariablesService } from '../mockChatVariables.js'; import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; import { MockLanguageModelToolsService } from '../tools/mockLanguageModelToolsService.js'; import { MockChatService } from './mockChatService.js'; -import { ChatSessionOptionsMap, IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { ChatSessionOptionsMap, IChatSession, IChatSessionHistoryItem, IChatSessionsService } from '../../../common/chatSessionsService.js'; import { MockChatSessionsService } from '../mockChatSessionsService.js'; import { AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, COPILOT_SKILL_URI_SCHEME, TROUBLESHOOT_SKILL_PATH } from '../../../common/promptSyntax/promptTypes.js'; @@ -1328,6 +1328,166 @@ suite('ChatService', () => { currentRef!.dispose(); await testService.waitForModelDisposals(); }); + + suite('loadRemoteSession progress streaming', () => { + const remoteScheme = 'remote-streaming-test'; + + interface IProvidedSessionOptions { + readonly progressObs?: ISettableObservable; + readonly isCompleteObs?: ISettableObservable; + readonly interruptActiveResponseCallback?: () => Promise; + readonly onDidStartServerRequest?: Event<{ prompt: string }>; + readonly history?: readonly IChatSessionHistoryItem[]; + } + + function setupRemoteProvider(opts: IProvidedSessionOptions): { resource: URI; provided: IChatSession } { + const resource = URI.from({ scheme: remoteScheme, path: '/session-' + generateId() }); + const mockSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + testDisposables.add(chatAgentService.registerAgent(remoteScheme, { ...getAgentData(remoteScheme), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(remoteScheme, { async invoke() { return {}; } })); + + const provided: IChatSession = { + sessionResource: resource, + history: opts.history ?? [{ type: 'request', prompt: 'hello', participant: remoteScheme }], + onWillDispose: Event.None, + progressObs: opts.progressObs, + isCompleteObs: opts.isCompleteObs, + interruptActiveResponseCallback: opts.interruptActiveResponseCallback, + onDidStartServerRequest: opts.onDidStartServerRequest, + dispose: () => { }, + }; + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(remoteScheme, { + provideChatSessionContent: () => Promise.resolve(provided), + })); + + return { resource, provided }; + } + + let idCounter = 0; + function generateId(): string { + return `${Date.now()}-${idCounter++}`; + } + + test('already-complete session at load time: no initial pending request, response is completed via autorun', async () => { + const progressObs = observableValue('progress', []); + const isCompleteObs = observableValue('isComplete', true); + let interruptCalls = 0; + const { resource } = setupRemoteProvider({ + progressObs, + isCompleteObs, + interruptActiveResponseCallback: async () => { interruptCalls++; return true; }, + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const model = ref.object as ChatModel; + const lastRequest = model.lastRequest!; + assert.strictEqual(lastRequest.response?.isComplete, true, 'Response should be completed through the isComplete autorun'); + + // No pending request should exist — cancelling is a noop and must not call the interrupt callback. + await testService.cancelCurrentRequestForSession(resource, 'test'); + assert.strictEqual(interruptCalls, 0, 'Interrupt callback should not be invoked when there is no pending request'); + }); + + test('active session at load time: cancelCurrentRequestForSession invokes the interrupt callback', async () => { + const progressObs = observableValue('progress', []); + const isCompleteObs = observableValue('isComplete', false); + let interruptCalls = 0; + const { resource } = setupRemoteProvider({ + progressObs, + isCompleteObs, + interruptActiveResponseCallback: async () => { interruptCalls++; return true; }, + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const model = ref.object as ChatModel; + assert.strictEqual(model.lastRequest?.response?.isComplete, false, 'Response must stay open while session is active'); + + await testService.cancelCurrentRequestForSession(resource, 'test'); + assert.strictEqual(interruptCalls, 1, 'Interrupt callback should be invoked once'); + }); + + test('transition of isCompleteObs to true clears pending request and completes response', async () => { + const progressObs = observableValue('progress', []); + const isCompleteObs = observableValue('isComplete', false); + let interruptCalls = 0; + const { resource } = setupRemoteProvider({ + progressObs, + isCompleteObs, + interruptActiveResponseCallback: async () => { interruptCalls++; return true; }, + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const model = ref.object as ChatModel; + const lastRequest = model.lastRequest!; + assert.strictEqual(lastRequest.response?.isComplete, false); + + // Simulate server finishing the turn. + isCompleteObs.set(true, undefined); + + assert.strictEqual(lastRequest.response?.isComplete, true, 'Response should complete when isCompleteObs transitions to true'); + + // Pending request entry should now be gone — cancel must be a noop. + await testService.cancelCurrentRequestForSession(resource, 'test'); + assert.strictEqual(interruptCalls, 0, 'Interrupt should not fire after the turn has completed'); + }); + + test('interrupt callback returning false installs a fresh pending request so cancel can be retried', async () => { + const progressObs = observableValue('progress', []); + const isCompleteObs = observableValue('isComplete', false); + const interruptResults = [false, true]; + const interruptInvocations: number[] = []; + const { resource } = setupRemoteProvider({ + progressObs, + isCompleteObs, + interruptActiveResponseCallback: async () => { + const index = interruptInvocations.length; + interruptInvocations.push(index); + return interruptResults[index] ?? true; + }, + }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + // First cancel: user rejects the interruption, so a new pending request is wired up. + await testService.cancelCurrentRequestForSession(resource, 'test-first'); + + // Second cancel: should find the freshly-installed pending request and fire the callback again. + await testService.cancelCurrentRequestForSession(resource, 'test-second'); + + assert.strictEqual(interruptInvocations.length, 2, 'Interrupt callback should be invoked on both cancel attempts'); + }); + + test('non-streaming session with isCompleteObs=true at load: response completes synchronously', async () => { + const isCompleteObs = observableValue('isComplete', true); + // Deliberately no progressObs / interruptActiveResponseCallback — falls through to the non-streaming branch. + const { resource } = setupRemoteProvider({ isCompleteObs }); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const model = ref.object as ChatModel; + assert.strictEqual(model.lastRequest?.response?.isComplete, true, 'Non-streaming session should complete response at load time'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index ffdda973dc385..ddc024803a1cd 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -46,15 +46,16 @@ import { IRemoteAgentService } from '../../../../../../workbench/services/remote import { basename } from '../../../../../../base/common/resources.js'; import { match } from '../../../../../../base/common/glob.js'; import { ChatModeKind, GeneralPurposeAgentName } from '../../../common/constants.js'; -import { IContextKeyService, ContextKeyExpr } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; -import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { observableValue } from '../../../../../../base/common/observable.js'; suite('ComputeAutomaticInstructions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + const localSessionType = 'local'; + let service: IPromptsService; let instaService: TestInstantiationService; let workspaceContextService: TestContextService; @@ -246,7 +247,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); { - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -263,7 +264,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, false); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -280,7 +281,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, false); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -297,7 +298,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, false); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -347,7 +348,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -382,7 +383,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Edit, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Edit, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -417,7 +418,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -459,7 +460,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -495,7 +496,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); @@ -531,7 +532,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file1.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file2.ts'))); @@ -565,7 +566,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -597,7 +598,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -645,7 +646,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/api/handler.ts'))); @@ -693,7 +694,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); @@ -733,7 +734,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'lib/utils.ts'))); @@ -780,7 +781,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); const referencedUri = URI.joinPath(rootFolderUri, '.github/instructions/referenced.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -816,7 +817,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -865,7 +866,7 @@ suite('ComputeAutomaticInstructions', () => { const level2Uri = URI.joinPath(rootFolderUri, '.github/instructions/level2.instructions.md'); const level3Uri = URI.joinPath(rootFolderUri, '.github/instructions/level3.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -921,7 +922,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -978,7 +979,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1024,7 +1025,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1081,11 +1082,11 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_runSubagent': true }, - ['*'] + ['*'], + localSessionType ); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1139,11 +1140,11 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1156,7 +1157,7 @@ suite('ComputeAutomaticInstructions', () => { // Both events should have hashed skill names (non-empty strings) for (const event of skillEvents) { assert.ok(typeof event.data.skillNameHash === 'string' && event.data.skillNameHash.length > 0, 'skillNameHash should be a non-empty string'); - assert.strictEqual(event.data.skillStorage, 'local', 'skillStorage should be local for workspace skills'); + assert.strictEqual(event.data.skillStorage, localSessionType, 'skillStorage should be local for workspace skills'); // Local skills have no extension or plugin provenance assert.strictEqual(event.data.extensionIdHash, '', 'extensionIdHash should be empty for local skills'); assert.strictEqual(event.data.extensionVersion, '', 'extensionVersion should be empty for local skills'); @@ -1208,11 +1209,11 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1221,7 +1222,7 @@ suite('ComputeAutomaticInstructions', () => { const skillEvents = telemetryEvents.filter(e => e.eventName === 'skillLoadedIntoContext'); assert.strictEqual(skillEvents.length, 1, 'Should emit only one event (manual skill excluded)'); - assert.strictEqual(skillEvents[0].data.skillStorage, 'local'); + assert.strictEqual(skillEvents[0].data.skillStorage, localSessionType); }); test('should not emit skillLoadedIntoContext when skills feature is disabled', async () => { @@ -1253,11 +1254,11 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, - undefined + undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1319,11 +1320,11 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, - undefined + undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1352,11 +1353,13 @@ suite('ComputeAutomaticInstructions', () => { }); suite('skill session-type filtering', () => { - test('non-local session includes skills without when', async () => { + test('non-local session includes skills without sessionTypes', async () => { const rootFolderName = 'skill-session-filter-test'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); + const testSessionType = 'remote-session'; + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); @@ -1372,65 +1375,55 @@ suite('ComputeAutomaticInstructions', () => { ]; sinon.stub(service, 'findAgentSkills').resolves(stubSkills); - // Set chatSessionType to a non-local value - const mockContextKeyService = new MockContextKeyService(); - mockContextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'remote-session'); - instaService.stub(IContextKeyService, mockContextKeyService); - - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, undefined, + testSessionType ); const variables = new ChatRequestVariableSet(); await contextComputer.collect(variables, CancellationToken.None); const allEntries = variables.asArray(); const skillEntries = allEntries.filter(e => isPromptTextVariableEntry(e) && e.value.includes('')); - assert.strictEqual(skillEntries.length, 1, 'Skills without when should be included in non-local sessions'); + assert.strictEqual(skillEntries.length, 1, 'Skills without sessionTypes should be included in non-local sessions'); }); - test('skills with matching when are included in non-local sessions', async () => { + test('skills with matching sessionTypes are included in non-local sessions', async () => { const rootFolderName = 'skill-when-match-test'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); + const testSessionType = 'remote-session'; + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); - const whenExpr = ContextKeyExpr.equals('chatSessionType', 'remote-session'); const stubSkills: IAgentSkill[] = [ { uri: URI.file(`${rootFolder}/.claude/skills/when-skill/SKILL.md`), storage: PromptsStorage.local, name: 'when-skill', - description: 'A skill with matching when clause', + description: 'A skill with matching session type', disableModelInvocation: false, userInvocable: true, - when: whenExpr!, + sessionTypes: [testSessionType], }, ]; sinon.stub(service, 'findAgentSkills').resolves(stubSkills); - // Set chatSessionType to a non-local value and make contextMatchesRules return true for the when expression - const mockContextKeyService = new MockContextKeyService(); - mockContextKeyService.createKey(ChatContextKeys.chatSessionType.key, 'remote-session'); - sinon.stub(mockContextKeyService, 'contextMatchesRules').returns(true); - instaService.stub(IContextKeyService, mockContextKeyService); - - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, undefined, + testSessionType ); const variables = new ChatRequestVariableSet(); await contextComputer.collect(variables, CancellationToken.None); const allEntries = variables.asArray(); const skillEntries = allEntries.filter(e => isPromptTextVariableEntry(e) && e.value.includes('')); - assert.strictEqual(skillEntries.length, 1, 'Skills with matching when should be included in non-local sessions'); + assert.strictEqual(skillEntries.length, 1, 'Skills with matching sessionTypes should be included in non-local sessions'); }); }); @@ -1469,11 +1462,11 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool - undefined + undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1556,11 +1549,11 @@ suite('ComputeAutomaticInstructions', () => { } ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_runSubagent': true }, // Enable runSubagent tool - ['*'], // Enable all subagents + ['*'], // Enable all subagents, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1610,11 +1603,11 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_runSubagent': true }, ['*'], + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1669,11 +1662,11 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1719,11 +1712,11 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, // No tools available undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1755,11 +1748,11 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool - undefined + undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1808,11 +1801,11 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1878,11 +1871,11 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance( - ComputeAutomaticInstructions, + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined, + localSessionType ); const variables = new ChatRequestVariableSet(); @@ -1922,7 +1915,7 @@ suite('ComputeAutomaticInstructions', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); await contextComputer.collect(variables, CancellationToken.None); @@ -1954,7 +1947,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1977,7 +1970,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2017,7 +2010,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2029,7 +2022,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables2 = new ChatRequestVariableSet(); variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2064,7 +2057,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2076,7 +2069,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables2 = new ChatRequestVariableSet(); variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2119,7 +2112,7 @@ suite('ComputeAutomaticInstructions', () => { await workspaceTrustService.setTrustedUris([URI.file(parentFolder)]); - const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const disabledParentVariables = new ChatRequestVariableSet(); disabledParentVariables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2134,7 +2127,7 @@ suite('ComputeAutomaticInstructions', () => { // Parent folder settings should allow finding both root and .claude CLAUDE files above the workspace folder. testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true); - const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const enabledParentVariables = new ChatRequestVariableSet(); enabledParentVariables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2180,7 +2173,7 @@ suite('ComputeAutomaticInstructions', () => { await workspaceTrustService.setTrustedUris([URI.file(parentFolder)]); - const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const disabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const disabledParentVariables = new ChatRequestVariableSet(); disabledParentVariables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2194,7 +2187,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS, true); - const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const enabledParentContextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const enabledParentVariables = new ChatRequestVariableSet(); enabledParentVariables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2231,7 +2224,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2243,7 +2236,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer2 = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables2 = new ChatRequestVariableSet(); variables2.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -2294,7 +2287,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -2341,7 +2334,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -2387,7 +2380,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -2441,7 +2434,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -2489,7 +2482,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is false testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, false); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -2543,7 +2536,7 @@ suite('ComputeAutomaticInstructions', () => { // Test when USE_CLAUDE_MD is true testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolder1Uri, 'src/file.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolder2Uri, 'src/file.js'))); @@ -2599,7 +2592,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, localSessionType); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index b6d615d2e0116..743114b8eaf5e 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; -import { Event } from '../../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; @@ -51,11 +51,41 @@ import { IExtensionService } from '../../../../../../services/extensions/common/ import { IRemoteAgentService } from '../../../../../../services/remote/common/remoteAgentService.js'; import { ChatModeKind } from '../../../../common/constants.js'; import { HookType } from '../../../../common/promptSyntax/hookTypes.js'; -import { IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextKeyChangeEvent, IContextKeyService } from '../../../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js'; import { IWorkspaceTrustManagementService } from '../../../../../../../platform/workspace/common/workspaceTrust.js'; +class TestPromptContextKeyService extends MockContextKeyService { + private readonly _onDidChangeContextEmitter = new Emitter(); + private _rulesMatch = false; + + override get onDidChangeContext(): Event { + return this._onDidChangeContextEmitter.event; + } + + override contextMatchesRules(): boolean { + return this._rulesMatch; + } + + setRulesMatch(value: boolean): void { + this._rulesMatch = value; + } + + fireDidChangeContext(keys: string[]): void { + const changedKeys = new Set(keys); + this._onDidChangeContextEmitter.fire({ + affectsSome: trackedKeys => keys.some(key => trackedKeys.has(key)), + allKeysContainedIn: trackedKeys => Array.from(changedKeys).every(key => trackedKeys.has(key)), + }); + } + + override dispose(): void { + this._onDidChangeContextEmitter.dispose(); + super.dispose(); + } +} + suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -492,7 +522,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.getInstructionFiles(CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, 'local'); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -663,7 +693,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.getInstructionFiles(CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, 'local'); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -737,7 +767,7 @@ suite('PromptsService', () => { ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined, 'local'); const context = new ChatRequestVariableSet(); context.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'README.md'))); @@ -2060,20 +2090,97 @@ suite('PromptsService', () => { registered2.dispose(); }); - test('Contributed file with when clause is included at discovery and propagated for later evaluation', async () => { + test('Contributed file with when clause is filtered inside PromptsService', async () => { const uri = URI.parse('file://extensions/my-extension/conditional.instructions.md'); const extension = {} as IExtensionDescription; + const contextKeyService = instaService.get(IContextKeyService) as MockContextKeyService; + const contextMatchesRulesStub = sinon.stub(contextKeyService, 'contextMatchesRules').returns(false); const registered = service.registerContributedFile( PromptsType.instructions, uri, extension, 'Conditional Instructions', 'Only when enabled', 'myFeature.enabled', ); - // `when` is no longer evaluated at discovery time; the file should always be - // included so consumers can evaluate it with session-scoped context later. const files = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - assert.strictEqual(files.length, 1, 'Should be included regardless of context key match'); - assert.strictEqual(files[0].uri.toString(), uri.toString()); + assert.strictEqual(files.length, 0, 'Should be filtered out when the when clause does not match'); + + registered.dispose(); + contextMatchesRulesStub.restore(); + + const enabledContextMatchesRulesStub = sinon.stub(contextKeyService, 'contextMatchesRules').returns(true); + const enabledRegistration = service.registerContributedFile( + PromptsType.instructions, uri, extension, + 'Conditional Instructions', 'Only when enabled', 'myFeature.enabled', + ); + + const enabledFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + assert.strictEqual(enabledFiles.length, 1, 'Should be included when the when clause matches'); + assert.strictEqual(enabledFiles[0].uri.toString(), uri.toString()); + + enabledRegistration.dispose(); + enabledContextMatchesRulesStub.restore(); + }); + + test('Provider file with when clause is filtered inside PromptsService', async () => { + const uri = URI.parse('file://extensions/test/myInstruction.instructions.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + const contextKeyService = instaService.get(IContextKeyService) as MockContextKeyService; + + const registered = service.registerPromptFileProvider(extension, PromptsType.instructions, { + providePromptFiles: async () => [{ uri, when: 'chatSessionType == local' }] + }); + + const contextMatchesRulesStub = sinon.stub(contextKeyService, 'contextMatchesRules').returns(false); + const files = await service.listPromptFilesForStorage(PromptsType.instructions, PromptsStorage.extension, CancellationToken.None); + assert.strictEqual(files.length, 0, 'Should be filtered out when the when clause does not match'); + contextMatchesRulesStub.restore(); + + const enabledContextMatchesRulesStub = sinon.stub(contextKeyService, 'contextMatchesRules').returns(true); + const enabledFiles = await service.listPromptFilesForStorage(PromptsType.instructions, PromptsStorage.extension, CancellationToken.None); + assert.strictEqual(enabledFiles.length, 1, 'Should be included when the when clause matches'); + assert.strictEqual(enabledFiles[0].uri.toString(), uri.toString()); + enabledContextMatchesRulesStub.restore(); + + registered.dispose(); + }); + + test('Provider when keys invalidate cached results when context changes', async () => { + const contextKeyService = disposables.add(new TestPromptContextKeyService()); + instaService.stub(IContextKeyService, contextKeyService); + const promptsService = disposables.add(instaService.createInstance(PromptsService)); + instaService.stub(IPromptsService, promptsService); + + const uri = URI.parse('file://extensions/test/conditional.instructions.md'); + await mockFiles(fileService, [{ + path: uri.path, + contents: [ + '---', + 'description: "Conditional Instructions"', + '---', + 'Instruction body', + ], + }]); + + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + const registered = promptsService.registerPromptFileProvider(extension, PromptsType.instructions, { + providePromptFiles: async () => [{ uri, when: 'myFeature.enabled' }] + }); + + contextKeyService.setRulesMatch(true); + const enabledFiles = await promptsService.getInstructionFiles(CancellationToken.None); + assert.strictEqual(enabledFiles.length, 1, 'Should include the provider instruction when the context matches'); + + contextKeyService.setRulesMatch(false); + contextKeyService.fireDidChangeContext(['myFeature.enabled']); + const disabledFiles = await promptsService.getInstructionFiles(CancellationToken.None); + assert.strictEqual(disabledFiles.length, 0, 'Should invalidate the cached provider instruction when the tracked key changes'); registered.dispose(); }); diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts index 901e76e199abf..08b55272ecd81 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostPty.ts @@ -9,6 +9,7 @@ import { DisposableStore, IReference } from '../../../../base/common/lifecycle.j import { URI } from '../../../../base/common/uri.js'; import { IProcessPropertyMap, ITerminalChildProcess, ITerminalLaunchError, ITerminalLaunchResult, ProcessPropertyType } from '../../../../platform/terminal/common/terminal.js'; import { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; import { ActionType, IActionEnvelope } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { TerminalClaimKind, type ITerminalContentPart, type ITerminalState } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -135,7 +136,7 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { terminal: this._terminalUri.toString(), claim: { kind: TerminalClaimKind.Client, clientId: this._connection.clientId }, name: this._options?.name, - cwd: this._options?.cwd?.toString(), + cwd: this._resolveCwdForProtocol(this._options?.cwd), cols: this._lastDimensions.cols > 0 ? this._lastDimensions.cols : undefined, rows: this._lastDimensions.rows > 0 ? this._lastDimensions.rows : undefined, }); @@ -285,6 +286,20 @@ export class AgentHostPty extends BasePty implements ITerminalChildProcess { } } + /** + * Resolves a cwd URI for sending over the protocol. Agent-host URIs + * are unwrapped to their original URI via {@link fromAgentHostUri}. + */ + private _resolveCwdForProtocol(cwd: URI | undefined): string | undefined { + if (!cwd) { + return undefined; + } + if (cwd.scheme === AGENT_HOST_SCHEME) { + return fromAgentHostUri(cwd).toString(); + } + return cwd.toString(); + } + input(data: string): void { if (this._inReplay) { return; diff --git a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts index 359dae2106f2c..45061dccdb093 100644 --- a/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts +++ b/src/vs/workbench/contrib/terminal/browser/agentHostTerminalService.ts @@ -3,15 +3,18 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; import { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; import { AgentHostPty } from './agentHostPty.js'; import { AhpTerminalCommandSource } from './ahpTerminalCommandSource.js'; import { ITerminalChatService, ITerminalInstance, ITerminalLocationOptions, ITerminalService } from './terminal.js'; +import { ITerminalProfileProvider, ITerminalProfileService } from '../common/terminal.js'; export interface IAgentHostTerminalCreateOptions { /** Human-readable terminal name. */ @@ -22,43 +25,255 @@ export interface IAgentHostTerminalCreateOptions { readonly location?: ITerminalLocationOptions; } +export interface IAgentHostEntry { + /** Display name for the profile. */ + readonly name: string; + /** Address or identifier for the host. */ + readonly address: string; + /** Getter for the connection (may be lazily resolved). */ + readonly getConnection: () => IAgentConnection | undefined; +} + +export interface IAgentHostTerminalProfileInfo { + readonly extensionIdentifier: string; + readonly profileId: string; + readonly title: string; + readonly address: string; +} + +const AGENT_HOST_PROFILE_EXT_ID = 'vscode.agent-host-terminal'; + export const IAgentHostTerminalService = createDecorator('agentHostTerminalService'); export interface IAgentHostTerminalService { readonly _serviceBrand: undefined; + /** Observable list of registered agent host terminal profiles. */ + readonly profiles: IObservable; + + /** + * Ensures a named profile exists for the given address, expanding any + * collapsed quickpick profile if needed. Returns the profile info, or + * `undefined` if no entry is registered for the address. + */ + getProfileForConnection(address: string): IAgentHostTerminalProfileInfo | undefined; + + /** + * Registers an agent host entry. The service reconciles entries into + * terminal profiles automatically. Dispose the returned disposable to + * remove the entry. + */ + registerEntry(entry: IAgentHostEntry): IDisposable; + /** * Creates a new interactive terminal on the given agent host connection. - * The terminal is user-visible (not a feature terminal) and appears in - * the terminal panel. */ createTerminal(connection: IAgentConnection, options?: IAgentHostTerminalCreateOptions): Promise; /** - * Attaches to an existing server-side terminal (e.g. one created by a - * tool) by subscribing to its state without creating a new process. - * The resulting ITerminalInstance is a hidden feature terminal suitable - * for mirroring live output in the chat UI. - * - * Deduplicates by terminalUri — calling twice with the same URI returns - * the same instance. + * Creates a terminal for the agent host registered at the given address, + * resolving the connection from the registered entry. Returns `undefined` + * if no entry is registered for the address. + */ + createTerminalForEntry(address: string, options?: IAgentHostTerminalCreateOptions): Promise; + + /** + * Attaches to an existing server-side terminal by subscribing to its + * state without creating a new process. */ reviveTerminal(connection: IAgentConnection, terminalUri: URI, terminalToolSessionId: string): Promise; + + /** + * Sets the default cwd used by profile providers when no explicit cwd + * is provided. Call with `undefined` to clear. + */ + setDefaultCwd(cwd: URI | undefined): void; } export class AgentHostTerminalService extends Disposable implements IAgentHostTerminalService { declare readonly _serviceBrand: undefined; + private readonly _entries: IAgentHostEntry[] = []; + private readonly _usedHosts = new Set(); + private readonly _profileRegistrations = this._register(new DisposableMap()); + private readonly _profiles = observableValue('agentHostTerminalProfiles', []); + readonly profiles: IObservable = this._profiles; + + private _defaultCwd: URI | undefined; + /** Revived terminal instances, keyed by terminal URI string. */ private readonly _revivedInstances = new Map(); constructor( @ITerminalService private readonly _terminalService: ITerminalService, - @ITerminalChatService private readonly _terminalChatService: ITerminalChatService + @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, + @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, + @IQuickInputService private readonly _quickInputService: IQuickInputService, ) { super(); } + // #region Profile management + + registerEntry(entry: IAgentHostEntry): IDisposable { + this._entries.push(entry); + this._reconcile(); + return toDisposable(() => { + const idx = this._entries.indexOf(entry); + if (idx >= 0) { + this._entries.splice(idx, 1); + this._reconcile(); + } + }); + } + + getProfileForConnection(address: string): IAgentHostTerminalProfileInfo | undefined { + const entry = this._entries.find(e => e.address === address); + if (!entry) { + return undefined; + } + // Expand the collapsed quickpick profile into a named one if needed + if (!this._profileRegistrations.has(address)) { + this._usedHosts.add(address); + this._reconcile(); + } + return this._profiles.get().find(p => p.address === address); + } + + setDefaultCwd(cwd: URI | undefined): void { + this._defaultCwd = cwd; + } + + private _reconcile(): void { + const entries = this._entries; + const desiredProfiles = new Map(); + + if (entries.length === 0) { + // No hosts — no profiles + } else if (entries.length === 1) { + desiredProfiles.set(entries[0].address, entries[0]); + } else { + // Multiple hosts — show named profiles for used ones + let displaying = 0; + for (const address of this._usedHosts) { + const entry = entries.find(e => e.address === address); + if (entry) { + displaying++; + desiredProfiles.set(entry.address, entry); + } + } + if (displaying === entries.length - 1) { + const missing = entries.find(e => !this._usedHosts.has(e.address)); + if (missing) { + desiredProfiles.set(missing.address, missing); + } + } else if (displaying < entries.length) { + desiredProfiles.set('__quickpick__', { + name: localize('agentHostTerminal.pick', "Agent Host\u2026"), + address: '__quickpick__', + getConnection: () => undefined, + }); + } + } + + // Diff registrations + for (const [key, entry] of desiredProfiles) { + if (!this._profileRegistrations.has(key)) { + this._registerProfile(key, entry, entries); + } + } + for (const key of this._profileRegistrations.keys()) { + if (!desiredProfiles.has(key)) { + this._profileRegistrations.deleteAndDispose(key); + } + } + + // Update observable + const infos: IAgentHostTerminalProfileInfo[] = []; + for (const [key] of desiredProfiles) { + infos.push({ + extensionIdentifier: AGENT_HOST_PROFILE_EXT_ID, + profileId: key, + title: key === '__quickpick__' + ? localize('agentHostTerminal.pick', "Agent Host\u2026") + : localize('agentHostTerminal.profileName', "Agent Host ({0})", desiredProfiles.get(key)!.name), + address: key, + }); + } + transaction(tx => { this._profiles.set(infos, tx); }); + } + + private _registerProfile(key: string, entry: IAgentHostEntry, allEntries: IAgentHostEntry[]): void { + const provider: ITerminalProfileProvider = { + createContributedTerminalProfile: async (options) => { + let connection: IAgentConnection | undefined; + let displayName = entry.name; + + if (key === '__quickpick__') { + const picks: (IQuickPickItem & { address: string; hostName: string })[] = allEntries.map(e => ({ + label: localize('agentHostTerminal.profileName', "Agent Host ({0})", e.name), + address: e.address, + hostName: e.name, + })); + const pick = await this._quickInputService.pick(picks, { + placeHolder: localize('agentHostTerminal.pickHost', "Select an agent host to open a terminal on"), + }); + if (!pick) { + return; + } + this._usedHosts.add(pick.address); + this._reconcile(); + displayName = pick.hostName; + connection = allEntries.find(e => e.address === pick.address)?.getConnection(); + } else { + connection = entry.getConnection(); + } + + if (!connection) { + return; + } + + await this.createTerminal(connection, { + name: localize('agentHostTerminal.profileName', "Agent Host ({0})", displayName), + cwd: options.cwd ? (typeof options.cwd === 'string' ? URI.file(options.cwd) : options.cwd) : this._defaultCwd, + location: options.location, + }); + }, + }; + + const title = key === '__quickpick__' + ? localize('agentHostTerminal.pick', "Agent Host\u2026") + : localize('agentHostTerminal.profileName', "Agent Host ({0})", entry.name); + + const store = new DisposableStore(); + store.add(this._terminalProfileService.registerTerminalProfileProvider( + AGENT_HOST_PROFILE_EXT_ID, + key, + provider, + )); + store.add(this._terminalProfileService.registerInternalContributedProfile({ + extensionIdentifier: AGENT_HOST_PROFILE_EXT_ID, + id: key, + title, + icon: 'remote', + })); + this._profileRegistrations.set(key, store); + } + + // #endregion + + async createTerminalForEntry(address: string, options?: IAgentHostTerminalCreateOptions): Promise { + const entry = this._entries.find(e => e.address === address); + if (!entry) { + return undefined; + } + const connection = entry.getConnection(); + if (!connection) { + return undefined; + } + return this.createTerminal(connection, options); + } + async createTerminal(connection: IAgentConnection, options?: IAgentHostTerminalCreateOptions): Promise { const terminalUri = URI.from({ scheme: 'agenthost-terminal', path: `/${generateUuid()}` }); const name = options?.name ?? localize('agentHostTerminal.default', "Agent Host Terminal"); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts index d8a91110c34db..d4d6ba9ee5385 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileService.ts @@ -43,6 +43,7 @@ export class TerminalProfileService extends Disposable implements ITerminalProfi private _platformConfigJustRefreshed = false; private readonly _refreshTerminalActionsDisposable = this._register(new MutableDisposable()); private readonly _profileProviders: Map> = new Map(); + private _defaultProfileOverride: { extensionIdentifier: string; id: string } | undefined; private readonly _onDidChangeAvailableProfiles = this._register(new Emitter()); get onDidChangeAvailableProfiles(): Event { return this._onDidChangeAvailableProfiles.event; } @@ -267,10 +268,28 @@ export class TerminalProfileService extends Disposable implements ITerminalProfi }); } + overrideDefaultProfile(extensionIdentifier: string, id: string): IDisposable { + this._defaultProfileOverride = { extensionIdentifier, id }; + return toDisposable(() => { + if (this._defaultProfileOverride?.extensionIdentifier === extensionIdentifier && this._defaultProfileOverride?.id === id) { + this._defaultProfileOverride = undefined; + } + }); + } + async getContributedDefaultProfile(shellLaunchConfig: IShellLaunchConfig): Promise { // prevents recursion with the MainThreadTerminalService call to create terminal // and defers to the provided launch config when an executable is provided if (shellLaunchConfig && !shellLaunchConfig.extHostTerminalId && !hasKey(shellLaunchConfig, { executable: true })) { + // Programmatic override takes priority over configuration + if (this._defaultProfileOverride) { + const overridden = this.contributedProfiles.find( + p => p.extensionIdentifier === this._defaultProfileOverride!.extensionIdentifier && p.id === this._defaultProfileOverride!.id + ); + if (overridden) { + return overridden; + } + } const key = await this.getPlatformKey(); const defaultProfileName = this._configurationService.getValue(`${TerminalSettingPrefix.DefaultProfile}${key}`); const contributedDefaultProfile = this.contributedProfiles.find(p => p.title === defaultProfileName); diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 91cb23c28cb74..41853cd468701 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -86,6 +86,13 @@ export interface ITerminalProfileService { registerInternalContributedProfile(profile: IExtensionTerminalProfile): IDisposable; getContributedProfileProvider(extensionIdentifier: string, id: string): ITerminalProfileProvider | undefined; registerTerminalProfileProvider(extensionIdentifier: string, id: string, profileProvider: ITerminalProfileProvider): IDisposable; + /** + * Overrides the default contributed terminal profile. When set, + * {@link getContributedDefaultProfile} returns the matching profile + * regardless of the user's configuration. Dispose the returned + * disposable to remove the override. + */ + overrideDefaultProfile(extensionIdentifier: string, id: string): IDisposable; } export interface ITerminalProfileProvider { diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index edc51741184ea..e5099ad04da0e 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -10,6 +10,7 @@ import { terminalAutoRepliesConfiguration } from '../terminalContrib/autoReplies import { TerminalChatCommandId, TerminalChatContextKeyStrings } from '../terminalContrib/chat/browser/terminalChat.js'; import { terminalInitialHintConfiguration } from '../terminalContrib/inlineHint/common/terminalInitialHintConfiguration.js'; import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.js'; +import { AgentSandboxSettingId } from '../../../platform/sandbox/common/settings.js'; import { terminalCommandGuideConfiguration } from '../terminalContrib/commandGuide/common/terminalCommandGuideConfiguration.js'; import { TerminalDeveloperCommandId } from '../terminalContrib/developer/common/terminal.developer.js'; import { defaultTerminalFindCommandToSkipShell } from '../terminalContrib/find/common/terminal.find.js'; @@ -46,8 +47,8 @@ export const enum TerminalContribSettingId { EnableAutoApprove = TerminalChatAgentToolsSettingId.EnableAutoApprove, ShellIntegrationTimeout = TerminalChatAgentToolsSettingId.ShellIntegrationTimeout, OutputLocation = TerminalChatAgentToolsSettingId.OutputLocation, - AgentSandboxEnabled = TerminalChatAgentToolsSettingId.AgentSandboxEnabled, - DeprecatedAgentSandboxEnabled = TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxEnabled, + AgentSandboxEnabled = AgentSandboxSettingId.AgentSandboxEnabled, + DeprecatedAgentSandboxEnabled = AgentSandboxSettingId.DeprecatedAgentSandboxEnabled, DeprecatedAgentSandboxLinuxFileSystem = TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxLinuxFileSystem, DeprecatedAgentSandboxMacFileSystem = TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxMacFileSystem, AgentSandboxLinuxFileSystem = TerminalChatAgentToolsSettingId.AgentSandboxLinuxFileSystem, diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index 8392360d31ba0..2a593a1fec466 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -97,6 +97,19 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ this._logService.warn('Attempted to register a terminal instance with an undefined tool session ID'); return; } + // If the instance is already registered with the same tool session id, skip to avoid + // accumulating duplicate `onDidDisposeSession`/`onDisposed` listeners (see #309906). + const existingToolSessionId = this._toolSessionIdByTerminalInstance.get(instance); + if (existingToolSessionId === terminalToolSessionId) { + return; + } + // The instance was previously registered under a different tool session id. Clean up the + // stale listener + mapping before installing the new ones so we keep at most one set of + // listeners per instance, regardless of how often it is re-registered. + if (existingToolSessionId !== undefined) { + this._terminalInstanceListenersByToolSessionId.deleteAndDispose(existingToolSessionId); + this._terminalInstancesByToolSessionId.delete(existingToolSessionId); + } this._terminalInstancesByToolSessionId.set(terminalToolSessionId, instance); this._toolSessionIdByTerminalInstance.set(instance, terminalToolSessionId); this._onDidRegisterTerminalInstanceForToolSession.fire(instance); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts new file mode 100644 index 0000000000000..5d5a077146ed7 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatService.test.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * 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, Event } from '../../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IChatService } from '../../../../chat/common/chatService/chatService.js'; +import { ITerminalInstance, ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { TerminalChatService } from '../../browser/terminalChatService.js'; + +/** + * Peeks at the protected `_size` counter on a base Emitter to assert listener counts in tests. + * The Emitter tracks this internally for leak detection, and it is the same counter that fires + * the "potential listener LEAK detected" warning we are guarding against here. + */ +function listenerCount(emitter: Emitter): number { + return (emitter as unknown as { _size: number })._size ?? 0; +} + +suite('TerminalChatService', () => { + const store = new DisposableStore(); + let service: TerminalChatService; + let onDidDisposeSessionEmitter: Emitter<{ readonly sessionResources: readonly URI[]; readonly reason: 'cleared' }>; + + setup(() => { + onDidDisposeSessionEmitter = store.add(new Emitter()); + + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IStorageService, store.add(new InMemoryStorageService())); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + instantiationService.stub(ITerminalService, new class extends mock() { + override onDidChangeInstances = Event.None; + override instances: readonly ITerminalInstance[] = []; + override foregroundInstances: readonly ITerminalInstance[] = []; + override whenConnected = Promise.resolve(); + }); + instantiationService.stub(IChatService, new class extends mock() { + override onDidDisposeSession = onDidDisposeSessionEmitter.event; + }); + + service = store.add(instantiationService.createInstance(TerminalChatService)); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('registerTerminalInstanceWithToolSession does not accumulate onDidDisposeSession listeners when re-registering the same instance (#309906)', () => { + const onInstanceDisposed = store.add(new Emitter()); + const instance = { onDisposed: onInstanceDisposed.event, shellLaunchConfig: {}, persistentProcessId: undefined, instanceId: 1 } as unknown as ITerminalInstance; + + // Simulate the real-world scenario: the RunInTerminalTool generates a fresh + // `terminalToolSessionId` (via generateUuid()) per invocation and re-registers the + // same foreground terminal instance. Before the fix, each call added another + // listener to `chatService.onDidDisposeSession`, eventually breaching the leak + // threshold. + const registrations = 50; + const initialListenerCount = listenerCount(onDidDisposeSessionEmitter); + for (let i = 0; i < registrations; i++) { + service.registerTerminalInstanceWithToolSession(`tool-session-${i}`, instance); + } + + const addedListeners = listenerCount(onDidDisposeSessionEmitter) - initialListenerCount; + + // After the fix at most one listener should be attached per instance (not one per call). + assert.ok( + addedListeners <= 1, + `Expected at most 1 listener on onDidDisposeSession after ${registrations} re-registrations, saw ${addedListeners}` + ); + + // Only the latest tool-session-id should remain mapped to the instance; stale + // mappings must have been cleaned up to prevent orphan entries from surviving. + assert.strictEqual( + service.getToolSessionIdForInstance(instance), + `tool-session-${registrations - 1}` + ); + }); + + test('registerTerminalInstanceWithToolSession is a no-op when the same tool session id is re-registered', () => { + const onInstanceDisposed = store.add(new Emitter()); + const instance = { onDisposed: onInstanceDisposed.event, shellLaunchConfig: {}, persistentProcessId: undefined, instanceId: 2 } as unknown as ITerminalInstance; + + service.registerTerminalInstanceWithToolSession('tool-session-a', instance); + const listenersAfterFirst = listenerCount(onDidDisposeSessionEmitter); + + service.registerTerminalInstanceWithToolSession('tool-session-a', instance); + const listenersAfterSecond = listenerCount(onDidDisposeSessionEmitter); + + assert.strictEqual(listenersAfterSecond, listenersAfterFirst, 're-registering the same (instance, id) pair should not add a new listener'); + assert.strictEqual(service.getToolSessionIdForInstance(instance), 'tool-session-a'); + }); +}); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts index 6bc32d8ee0037..be32be51c9659 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/terminal.chatAgentTools.contribution.ts @@ -10,6 +10,7 @@ import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; import { registerWorkbenchContribution2, WorkbenchPhase, type IWorkbenchContribution } from '../../../../common/contributions.js'; import { IChatWidgetService } from '../../../chat/browser/chat.js'; @@ -140,8 +141,8 @@ export class ChatAgentToolsContribution extends Disposable implements IWorkbench // sandbox state. this._register(this._configurationService.onDidChangeConfiguration(e => { if ( - e.affectsConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxEnabled) || - e.affectsConfiguration(TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxEnabled) || + e.affectsConfiguration(AgentSandboxSettingId.AgentSandboxEnabled) || + e.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) || e.affectsConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains) || e.affectsConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains) || e.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains) || diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index cbc13312f5e13..f97e93314aae0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -874,15 +874,17 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // If forceConfirmationReason is set, always show confirmation regardless of auto-approval const shouldShowConfirmation = (!isFinalAutoApproved && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; (toolSpecificData as IRunInTerminalToolInvocationData).requiresConfirmationForRetry = (!isAutoApprovedByRules && !isSessionAutoApproved) || context.forceConfirmationReason !== undefined; + const explanation = args.explanation || localize('runInTerminal.defaultExplanation', "No explanation provided"); + const goal = args.goal || localize('runInTerminal.defaultGoal', "No goal provided"); const confirmationMessage = requiresUnsandboxConfirmation ? new MarkdownString(localize( 'runInTerminal.unsandboxed.confirmationMessage', "Explanation: {0}\n\nGoal: {1}\n\nReason for leaving the sandbox: {2}", - args.explanation, - args.goal, + explanation, + goal, requestUnsandboxedExecutionReason || localize('runInTerminal.unsandboxed.confirmationMessage.defaultReason', "The model indicated that this command needs unsandboxed access.") )) - : new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", args.explanation, args.goal)); + : new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", explanation, goal)); const confirmationMessages = shouldShowConfirmation ? { title: confirmationTitle, message: confirmationMessage, @@ -1391,7 +1393,11 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText += `${outputAnalyzerMessage}\n`; } resultText += pollingResult.output; - resultText += `\nEvaluate the terminal output to determine if the command is waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. If you are certain about the exact sequence of upcoming prompts for this command (e.g. well-known interactive commands like npm init), include all of them in a single askQuestions call. Otherwise, ask only about the currently visible prompt. Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.`; + if (isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService)) { + resultText += `\nIf the command is waiting for input (not a normal shell prompt), determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time.`; + } else { + resultText += `\nIf the command is waiting for input (not a normal shell prompt), call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each.`; + } } else if (pollingResult) { resultText += `\n The command is still running, with output:\n`; if (outputAnalyzerMessage) { @@ -1697,14 +1703,21 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } + const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); if (didInputNeeded) { - resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. Evaluate the terminal output to determine if the command is actually waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. If you are certain about the exact sequence of upcoming prompts for this command (e.g. well-known interactive commands like npm init), include all of them in a single askQuestions call. Otherwise, ask only about the currently visible prompt. Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.\n\n`); + if (isAutoApproved) { + resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. If it IS waiting for input (not a normal shell prompt), determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time.\n\n`); + } else { + resultText.push(`Note: The command is running in terminal ID ${termId} and may be waiting for input. If it IS waiting for input (not a normal shell prompt), call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each.\n\n`); + } } else if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { const notificationHint = shouldSendNotifications ? ' You will be automatically notified on your next turn when it completes.' : ''; - const inputAction = `If it IS waiting for input, you MUST call the vscode_askQuestions tool to ask the user what values to provide. If you are certain about the exact sequence of upcoming prompts for this command (e.g. well-known interactive commands like npm init), include all of them in a single askQuestions call. Otherwise, ask only about the currently visible prompt. Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.`; - resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint} Use ${TerminalToolId.GetTerminalOutput} to check output before then, ${TerminalToolId.SendToTerminal} to send further input, or ${TerminalToolId.KillTerminal} to stop it. Do NOT use sleep or manual polling to wait. Evaluate the terminal output to determine if the command is waiting for input (e.g. a password prompt, confirmation, or interactive question). A normal shell prompt does NOT count as waiting for input. ${inputAction}\n\n`); + const inputAction = isAutoApproved + ? `If it IS waiting for input (not a normal shell prompt), determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time.` + : `If it IS waiting for input (not a normal shell prompt), call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each.`; + resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint} Use ${TerminalToolId.GetTerminalOutput} to check output, ${TerminalToolId.SendToTerminal} to send input, or ${TerminalToolId.KillTerminal} to stop it. ${inputAction}\n\n`); } const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { @@ -2185,7 +2198,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } lastInputNeededOutput = currentOutput; lastInputNeededNotificationTime = now; - const inputAction = `You MUST call the vscode_askQuestions tool to ask the user what values to provide for all anticipated prompts at once (include upcoming prompts you can predict from the command, not just the currently visible one). Do NOT respond with a text message asking the user — use the tool. Then send each answer one at a time using ${TerminalToolId.SendToTerminal} with id "${termId}", calling ${TerminalToolId.GetTerminalOutput} between each to read the next prompt before sending the next answer.`; + const isAutoApproved = isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); + const inputAction = isAutoApproved + ? `Determine the answer and call ${TerminalToolId.SendToTerminal}. Then call ${TerminalToolId.GetTerminalOutput} to read the next prompt. Repeat one prompt at a time. A normal shell prompt does NOT count as waiting for input.` + : `Call the vscode_askQuestions tool to ask the user. Then send each answer using ${TerminalToolId.SendToTerminal}, calling ${TerminalToolId.GetTerminalOutput} between each. A normal shell prompt does NOT count as waiting for input.`; const message = `[Terminal ${termId} notification: command is waiting for input. ${inputAction}]\nTerminal output:\n${currentOutput}`; this._logService.debug(`RunInTerminalTool: Input needed in background terminal ${termId}, notifying chat session`); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts index d970eaf235977..8bba66f34e8eb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts @@ -16,7 +16,7 @@ import { IChatWidgetService } from '../../../../chat/browser/chat.js'; import { IChatService, IChatMultiSelectAnswer, IChatQuestionAnswerValue, IChatQuestionCarousel, IChatSingleSelectAnswer } from '../../../../chat/common/chatService/chatService.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 { URI } from '../../../../../../base/common/uri.js'; -import { ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { ITerminalChatService, ITerminalService } from '../../../../terminal/browser/terminal.js'; import { getOutput } from '../outputHelpers.js'; import { buildCommandDisplayText, normalizeCommandForExecution } from '../runInTerminalHelpers.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; @@ -99,6 +99,7 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { @IConfigurationService private readonly _configurationService: IConfigurationService, @IChatService private readonly _chatService: IChatService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, @ITerminalService private readonly _terminalService: ITerminalService, ) { super(); @@ -146,14 +147,17 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { : localize('send.confirm.message', "Run {0} in terminal {1}", toMarkdownInlineCode(buildCommandDisplayText(args.command)), safeTerminalLabel); if (instanceId !== undefined) { const focusUri = createCommandUri(FocusTerminalByIdCommandId, instanceId); - confirmationMessage.appendMarkdown(`${baseMessage} — [$(terminal) ${localize('focusTerminal', "Focus Terminal")}](${focusUri})`); + confirmationMessage.appendMarkdown(`${baseMessage} — [${localize('focusTerminal', "Focus Terminal")}](${focusUri})`); } else { confirmationMessage.appendMarkdown(baseMessage); } // Determine auto-approval, aligned with runInTerminal const chatSessionResource = context.chatSessionResource; - const isSessionAutoApproved = chatSessionResource && isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService); + const isSessionAutoApproved = chatSessionResource && ( + isSessionAutoApproveLevel(chatSessionResource, this._configurationService, this._chatWidgetService, this._chatService) || + this._terminalChatService.hasChatSessionAutoApproval(chatSessionResource) + ); // send_to_terminal normally requires confirmation in default approvals mode // because the text may be arbitrary input (passwords, confirmations, etc.) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 3fa4de368cf3c..b221aeb42ac09 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -7,6 +7,7 @@ import type { IStringDictionary } from '../../../../../base/common/collections.j import type { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; import { localize } from '../../../../../nls.js'; import { type IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; import { terminalProfileBaseProperties } from '../../../../../platform/terminal/common/terminalPlatformConfiguration.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; @@ -19,7 +20,6 @@ export const enum TerminalChatAgentToolsSettingId { BlockDetectedFileWrites = 'chat.tools.terminal.blockDetectedFileWrites', ShellIntegrationTimeout = 'chat.tools.terminal.shellIntegrationTimeout', OutputLocation = 'chat.tools.terminal.outputLocation', - AgentSandboxEnabled = 'chat.agent.sandbox.enabled', AgentSandboxLinuxFileSystem = 'chat.agent.sandbox.fileSystem.linux', AgentSandboxMacFileSystem = 'chat.agent.sandbox.fileSystem.mac', AgentSandboxAdvancedRuntime = 'chat.agent.sandbox.advanced.runtime', @@ -33,7 +33,6 @@ export const enum TerminalChatAgentToolsSettingId { TerminalProfileMacOs = 'chat.tools.terminal.terminalProfile.osx', TerminalProfileWindows = 'chat.tools.terminal.terminalProfile.windows', - DeprecatedAgentSandboxEnabled = 'chat.agent.sandbox', DeprecatedAgentSandboxLinuxFileSystem = 'chat.agent.sandboxFileSystem.linux', DeprecatedAgentSandboxMacFileSystem = 'chat.agent.sandboxFileSystem.mac', DeprecatedAutoApproveCompatible = 'chat.agent.terminal.autoApprove', @@ -43,11 +42,6 @@ export const enum TerminalChatAgentToolsSettingId { DeprecatedAutoApprove4 = 'github.copilot.chat.agent.terminal.denyList', } -export const enum TerminalChatAgentToolsSandboxEnabledValue { - Off = 'off', - On = 'on', -} - export interface ITerminalChatAgentToolsConfiguration { autoApprove: { [key: string]: boolean }; commandReportingAllowList: { [key: string]: boolean }; @@ -524,15 +518,15 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary('terminalSandboxService'); - -export interface ITerminalSandboxResolvedNetworkDomains { - allowedDomains: string[]; - deniedDomains: string[]; -} - -export const enum TerminalSandboxPrerequisiteCheck { - Config = 'config', - Dependencies = 'dependencies', -} - -export interface ITerminalSandboxPrerequisiteCheckResult { - enabled: boolean; - sandboxConfigPath: string | undefined; - failedCheck: TerminalSandboxPrerequisiteCheck | undefined; - missingDependencies?: string[]; -} - -export interface ITerminalSandboxWrapResult { - command: string; - isSandboxWrapped: boolean; - blockedDomains?: string[]; - deniedDomains?: string[]; - requiresUnsandboxConfirmation?: boolean; -} - -/** - * Abstraction over terminal operations needed by the install flow. - * Provided by the browser-layer caller so the common-layer service - * does not import browser types directly. - */ -export interface ISandboxDependencyInstallTerminal { - sendText(text: string, addNewLine?: boolean): Promise; - focus(): void; - capabilities: { - get(id: TerminalCapability.CommandDetection): { onCommandFinished: Event<{ exitCode: number | undefined }> } | undefined; - onDidAddCapability: Event<{ id: TerminalCapability }>; - }; - onDidInputData: Event; - onDisposed: Event; -} +export { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; +export type { ISandboxDependencyInstallOptions, ISandboxDependencyInstallResult, ISandboxDependencyInstallTerminal, ITerminalSandboxPrerequisiteCheckResult, ITerminalSandboxResolvedNetworkDomains, ITerminalSandboxWrapResult } from '../../../../../platform/sandbox/common/terminalSandboxService.js'; /** * Context passed to the password prompt during dependency installation. @@ -88,35 +49,6 @@ interface ISandboxDependencyInstallTerminalContext { didSendInstallCommand(): boolean; } -export interface ISandboxDependencyInstallOptions { - /** - * Creates or obtains a terminal for running the install command. - */ - createTerminal(): Promise; - /** - * Focuses the terminal for password entry. - */ - focusTerminal(terminal: ISandboxDependencyInstallTerminal): Promise; -} - -export interface ISandboxDependencyInstallResult { - exitCode: number | undefined; -} - -export interface ITerminalSandboxService { - readonly _serviceBrand: undefined; - isEnabled(): Promise; - getOS(): Promise; - checkForSandboxingPrereqs(forceRefresh?: boolean): Promise; - wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult; - getSandboxConfigPath(forceRefresh?: boolean): Promise; - getTempDir(): URI | undefined; - setNeedsForceUpdateConfigFile(): void; - getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains; - getMissingSandboxDependencies(): Promise; - installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise; -} - export class TerminalSandboxService extends Disposable implements ITerminalSandboxService { readonly _serviceBrand: undefined; private _srtPath: string | undefined; @@ -161,8 +93,8 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb this._register(Event.runAndSubscribe(this._configurationService.onDidChangeConfiguration, (e: IConfigurationChangeEvent | undefined) => { // If terminal sandbox settings changed, update sandbox config. if ( - e?.affectsConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxEnabled) || - e?.affectsConfiguration(TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxEnabled) || + e?.affectsConfiguration(AgentSandboxSettingId.AgentSandboxEnabled) || + e?.affectsConfiguration(AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) || e?.affectsConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains) || e?.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedSandboxAllowedNetworkDomains) || e?.affectsConfiguration(AgentNetworkDomainSettingId.DeprecatedOldAllowedNetworkDomains) || @@ -538,7 +470,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb if (os === OperatingSystem.Windows) { return false; } - return this._isSandboxEnabled(this._getSettingValue(TerminalChatAgentToolsSettingId.AgentSandboxEnabled, TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxEnabled) ?? TerminalChatAgentToolsSandboxEnabledValue.Off); + return this._isSandboxEnabled(this._getSettingValue(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) ?? AgentSandboxEnabledValue.Off); } private async _resolveSrtPath(): Promise { @@ -696,11 +628,11 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb } - private _isSandboxEnabled(value: TerminalChatAgentToolsSandboxEnabledValue | boolean): boolean { - return value === true || value === TerminalChatAgentToolsSandboxEnabledValue.On; + private _isSandboxEnabled(value: AgentSandboxEnabledValue | boolean): boolean { + return value === true || value === AgentSandboxEnabledValue.On; } - private _getSettingValue(settingId: TerminalChatAgentToolsSettingId | AgentNetworkDomainSettingId, ...deprecatedSettingIds: (TerminalChatAgentToolsSettingId | AgentNetworkDomainSettingId)[]): T | undefined { + private _getSettingValue(settingId: TerminalChatAgentToolsSettingId | AgentNetworkDomainSettingId | AgentSandboxSettingId, ...deprecatedSettingIds: (TerminalChatAgentToolsSettingId | AgentNetworkDomainSettingId | AgentSandboxSettingId)[]): T | undefined { const setting = this._configurationService.inspect(settingId); if (setting.userValue !== undefined) { return setting.value; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts index 131abd1c75386..c97a2e32bad5b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts @@ -12,11 +12,13 @@ import { SendToTerminalTool, SendToTerminalToolData } from '../../browser/tools/ import { RunInTerminalTool, type IActiveTerminalExecution } from '../../browser/tools/runInTerminalTool.js'; import type { IToolInvocation, IToolInvocationPreparationContext } from '../../../../chat/common/tools/languageModelToolsService.js'; import type { ITerminalExecuteStrategyResult } from '../../browser/executeStrategy/executeStrategy.js'; -import { ITerminalChatService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; +import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import type { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IChatService } from '../../../../chat/common/chatService/chatService.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { IChatWidget, IChatWidgetService } from '../../../../chat/browser/chat.js'; +import { ChatPermissionLevel } from '../../../../chat/common/constants.js'; suite('SendToTerminalTool', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -350,4 +352,60 @@ suite('SendToTerminalTool', () => { const thirdMsg = third.pastTenseMessage as IMarkdownString; assert.ok(thirdMsg.value.includes('description'), 'third call should show description question'); }); + + test('prepareToolInvocation shows confirmation in default permission mode', async () => { + const prepared = await tool.prepareToolInvocation( + createPreparationContext(KNOWN_TERMINAL_ID, 'hello'), + CancellationToken.None, + ); + + assert.ok(prepared); + assert.ok(prepared.confirmationMessages, 'should show confirmation in default mode'); + assert.strictEqual(prepared.confirmationMessages.title, 'Send to Terminal'); + }); + + test('prepareToolInvocation skips confirmation in auto-approve mode', async () => { + const sessionResource = URI.parse('chat-session://test-session'); + instantiationService.stub(IChatWidgetService, { + getWidgetBySessionResource: () => ({ + input: { + currentModeInfo: { + permissionLevel: ChatPermissionLevel.AutoApprove, + }, + }, + }) as unknown as IChatWidget, + lastFocusedWidget: undefined, + }); + tool = store.add(instantiationService.createInstance(SendToTerminalTool)); + + const prepared = await tool.prepareToolInvocation( + createPreparationContext(KNOWN_TERMINAL_ID, 'hello', sessionResource), + CancellationToken.None, + ); + + assert.ok(prepared); + assert.strictEqual(prepared.confirmationMessages, undefined, 'should skip confirmation in auto-approve mode'); + }); + + test('prepareToolInvocation Focus Terminal link does not contain $(terminal)', async () => { + const mockExecution = createMockExecution('output'); + (mockExecution.instance as { instanceId: number }).instanceId = 42; + (mockExecution.instance as { title: string }).title = 'node'; + RunInTerminalTool.getExecution = () => mockExecution; + instantiationService.stub(ITerminalService, { + getInstanceFromId: () => undefined, + }); + tool = store.add(instantiationService.createInstance(SendToTerminalTool)); + + const prepared = await tool.prepareToolInvocation( + createPreparationContext(KNOWN_TERMINAL_ID, 'hello'), + CancellationToken.None, + ); + + assert.ok(prepared); + assert.ok(prepared.confirmationMessages); + const message = prepared.confirmationMessages.message as IMarkdownString; + assert.ok(!message.value.includes('$(terminal)'), 'Focus Terminal link should not contain literal $(terminal)'); + assert.ok(message.value.includes('Focus Terminal'), 'should contain Focus Terminal link text'); + }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 097140d14a466..c46bea5e06b2b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -16,8 +16,9 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { TerminalChatAgentToolsSandboxEnabledValue, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { Event, Emitter } from '../../../../../../base/common/event.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; @@ -175,7 +176,7 @@ suite('TerminalSandboxService - network domains', () => { workspaceContextService.setWorkspaceFolders([URI.file('/workspace-one')]); // Setup default configuration - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxEnabled, TerminalChatAgentToolsSandboxEnabledValue.On); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, []); configurationService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, []); @@ -631,10 +632,10 @@ suite('TerminalSandboxService - network domains', () => { test('should not fall back to deprecated settings outside user scope', async () => { const originalInspect = configurationService.inspect.bind(configurationService); configurationService.inspect = (key: string) => { - if (key === TerminalChatAgentToolsSettingId.AgentSandboxEnabled) { + if (key === AgentSandboxSettingId.AgentSandboxEnabled) { return { value: undefined, - defaultValue: TerminalChatAgentToolsSandboxEnabledValue.Off, + defaultValue: AgentSandboxEnabledValue.Off, userValue: undefined, userLocalValue: undefined, userRemoteValue: undefined, @@ -644,7 +645,7 @@ suite('TerminalSandboxService - network domains', () => { policyValue: undefined, } as ReturnType>; } - if (key === TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxEnabled) { + if (key === AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) { return { value: true, defaultValue: false, @@ -668,10 +669,10 @@ suite('TerminalSandboxService - network domains', () => { test('should fall back to deprecated chat.agent.sandbox setting in user scope', async () => { const originalInspect = configurationService.inspect.bind(configurationService); configurationService.inspect = (key: string) => { - if (key === TerminalChatAgentToolsSettingId.AgentSandboxEnabled) { + if (key === AgentSandboxSettingId.AgentSandboxEnabled) { return { value: undefined, - defaultValue: TerminalChatAgentToolsSandboxEnabledValue.Off, + defaultValue: AgentSandboxEnabledValue.Off, userValue: undefined, userLocalValue: undefined, userRemoteValue: undefined, @@ -681,7 +682,7 @@ suite('TerminalSandboxService - network domains', () => { policyValue: undefined, } as ReturnType>; } - if (key === TerminalChatAgentToolsSettingId.DeprecatedAgentSandboxEnabled) { + if (key === AgentSandboxSettingId.DeprecatedAgentSandboxEnabled) { return { value: true, defaultValue: false, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 222da7f3af9c3..d750abd4eb3c1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -43,8 +43,9 @@ import { ITerminalProfileResolverService } from '../../../../terminal/common/ter import type { ICommandLinePresenter } from '../../browser/tools/commandLinePresenter/commandLinePresenter.js'; import { createRunInTerminalToolData, RunInTerminalTool, shouldAutomaticallyRetryUnsandboxed, type IRunInTerminalInputParams } from '../../browser/tools/runInTerminalTool.js'; import { ShellIntegrationQuality } from '../../browser/toolTerminalCreator.js'; -import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSandboxEnabledValue, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; +import { terminalChatAgentToolsConfiguration, TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; import { AgentNetworkDomainSettingId } from '../../../../../../platform/networkFilter/common/settings.js'; +import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../../platform/sandbox/common/settings.js'; import { TerminalChatService } from '../../../chat/browser/terminalChatService.js'; import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { IAgentSessionsService } from '../../../../chat/browser/agentSessions/agentSessionsService.js'; @@ -1118,6 +1119,20 @@ suite('RunInTerminalTool', () => { assertConfirmationRequired(result, `Run command in \`bash\` within \`${isWindows ? '\\tmp' : '~/tmp'}\`?`); }); + test('should not show undefined in confirmation message when explanation and goal are missing', async () => { + const params: Partial = { + command: 'rm file.txt', + }; + delete params.explanation; + delete params.goal; + const result = await executeToolTest(params); + assertConfirmationRequired(result); + const message = result?.confirmationMessages?.message; + ok(message, 'Expected confirmation message to be defined'); + const messageText = typeof message === 'string' ? message : message.value; + ok(!messageText.includes('undefined'), `Confirmation message should not contain "undefined", got: ${messageText}`); + }); + test('should use withLanguage inDirectory title when presenter returns languageDisplayName with cd prefix', async () => { const workspaceFolder = URI.file(isWindows ? 'C:\\workspace\\project' : '/workspace/project'); const workspace = new Workspace('test', [toWorkspaceFolder(workspaceFolder)]); @@ -2470,10 +2485,10 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { // Enable sandbox and fire config change sandboxEnabled = true; - configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxEnabled, TerminalChatAgentToolsSandboxEnabledValue.On); + configurationService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); configurationService.onDidChangeConfigurationEmitter.fire({ - affectsConfiguration: (key: string) => key === TerminalChatAgentToolsSettingId.AgentSandboxEnabled, - affectedKeys: new Set([TerminalChatAgentToolsSettingId.AgentSandboxEnabled]), + affectsConfiguration: (key: string) => key === AgentSandboxSettingId.AgentSandboxEnabled, + affectedKeys: new Set([AgentSandboxSettingId.AgentSandboxEnabled]), source: ConfigurationTarget.USER, change: null!, }); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts index af332edd79309..6f785eaa4a051 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/startupPage.ts @@ -236,8 +236,8 @@ export class StartupPageRunnerContribution extends Disposable implements IWorkbe return; // skip welcome flag is set } - if (isWeb && !this.environmentService.remoteAuthority) { - return; // not supported on web without remote authority (e.g. github.dev) + if (isWeb) { + return; // not supported on web (e.g. codespaces, github.dev) } if (!this.configurationService.getValue('workbench.welcomePage.experimentalOnboarding')) { diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css index d47088143aca8..8945687a86ca2 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/media/variationA.css @@ -959,19 +959,6 @@ grid-template-columns: 1fr; } - .onboarding-a-ext-list { - grid-template-columns: 1fr; - } - - .onboarding-a-ext-row { - grid-template-columns: auto 1fr; - } - - .onboarding-a-ext-install { - grid-column: 2; - justify-self: start; - } - .onboarding-a-personalize, .onboarding-a-sessions { justify-content: flex-start; @@ -984,118 +971,6 @@ opacity: 0.7; } -/* Extensions step */ -.onboarding-a-extensions { - display: flex; - flex-direction: column; - flex: 1; - min-height: 0; - width: 100%; - margin: 0 auto; -} - -.onboarding-a-ext-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-content: start; - gap: 10px; -} - -.onboarding-a-ext-row { - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - gap: 12px; - padding: 12px 14px; - border-radius: 8px; - border: 1px solid var(--vscode-widget-border, rgba(255, 255, 255, 0.08)); - background: var(--vscode-editorWidget-background, var(--vscode-sideBar-background)); -} - -.onboarding-a-ext-icon { - width: 36px; - height: 36px; - border-radius: 6px; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.onboarding-a-ext-icon .codicon { - font-size: 28px; - color: var(--vscode-textLink-foreground, #3794ff); -} - -.onboarding-a-ext-icon-img { - width: 100%; - height: 100%; - border-radius: 6px; - object-fit: contain; -} - -.onboarding-a-ext-info { - flex: 1; - min-width: 0; -} - -.onboarding-a-ext-name-row { - display: flex; - align-items: baseline; - gap: 6px; -} - -.onboarding-a-ext-name { - font-size: 13px; - font-weight: 600; -} - -.onboarding-a-ext-publisher { - font-size: 11px; - color: var(--vscode-descriptionForeground); -} - -.onboarding-a-ext-desc { - font-size: 12px; - color: var(--vscode-descriptionForeground); - line-height: 1.4; - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.onboarding-a-ext-install { - padding: 4px 12px; - border-radius: 4px; - border: none; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - font-size: 12px; - font-family: inherit; - cursor: pointer; - flex-shrink: 0; - outline: none; -} - -.onboarding-a-ext-install:hover { - background: var(--vscode-button-hoverBackground); -} - -.onboarding-a-ext-install:focus-visible { - outline: none; - box-shadow: - 0 0 0 2px var(--vscode-editorWidget-background, #252526), - 0 0 0 4px var(--vscode-focusBorder); -} - -.onboarding-a-ext-install.installed { - background: var(--vscode-testing-iconPassed, #73c991); - color: var(--vscode-editor-background); - cursor: default; -} - @media (prefers-reduced-motion: reduce) { .onboarding-a-overlay, .onboarding-a-card, diff --git a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts index 9c2dcb921ed4a..e1486cb5c587d 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/browser/onboardingVariationA.ts @@ -20,7 +20,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IWorkbenchThemeService } from '../../../services/themes/common/workbenchThemeService.js'; -import { EXTENSION_INSTALL_SKIP_WALKTHROUGH_CONTEXT, IGalleryExtension, IExtensionGalleryService, IExtensionManagementService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; +import { EXTENSION_INSTALL_SKIP_WALKTHROUGH_CONTEXT, IExtensionGalleryService, IExtensionManagementService } from '../../../../platform/extensionManagement/common/extensionManagement.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; @@ -121,7 +121,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi private selectedThemeId = 'dark-2026'; private selectedKeymapId = 'vscode'; private _detectedEditorIds: Set | undefined; - private _galleryExtensions: Map | undefined; private _userSignedIn = false; private selectedAiMode: AiCollaborationMode = AiCollaborationMode.Balanced; @@ -152,9 +151,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi // Start detecting installed editors early so results are ready by the Personalize step this._detectInstalledEditors().then(ids => { this._detectedEditorIds = ids; }); - - // Pre-fetch gallery data so extension icons are ready by the Extensions step - this._prefetchGalleryExtensions(); } get isShowing(): boolean { @@ -369,8 +365,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi this._renderAgentSessionsSubtitle(this.subtitleEl); } else if (stepId === OnboardingStepId.Personalize) { this._renderPersonalizeSubtitle(this.subtitleEl); - } else if (stepId === OnboardingStepId.Extensions) { - this._renderExtensionsSubtitle(this.subtitleEl); } else { this.subtitleEl.textContent = getOnboardingStepSubtitle(stepId); } @@ -384,9 +378,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi case OnboardingStepId.Personalize: this._renderPersonalizeStep(this.contentEl); break; - case OnboardingStepId.Extensions: - this._renderExtensionsStep(this.contentEl); - break; case OnboardingStepId.AiPreference: this._renderAiPreferenceStep(this.contentEl); break; @@ -781,20 +772,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi ); } - private _renderExtensionsSubtitle(container: HTMLElement): void { - clearNode(container); - const modifier = isMacintosh ? 'Cmd' : 'Ctrl'; - container.append( - localize('onboarding.extensions.subtitle.prefix', "Install extensions to enhance your workflow. Press "), - this._createKbd(localize({ key: 'onboarding.extensions.subtitle.modifier', comment: ['Keyboard modifier key'] }, "{0}", modifier)), - '+', - this._createKbd(localize('onboarding.extensions.subtitle.shift', "Shift")), - '+', - this._createKbd(localize('onboarding.extensions.subtitle.x', "X")), - localize('onboarding.extensions.subtitle.suffix', " to browse the Extension Marketplace."), - ); - } - private _createThemeCard(parent: HTMLElement, theme: IOnboardingThemeOption, allCards: HTMLElement[]): void { const card = this._registerStepFocusable(append(parent, $('div.onboarding-a-theme-card'))); allCards.push(card); @@ -837,122 +814,9 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } // ===================================================================== - // Step: Extensions + // Theme / Keymap helpers // ===================================================================== - private _renderExtensionsStep(container: HTMLElement): void { - const wrapper = append(container, $('div.onboarding-a-extensions')); - - const extList = append(wrapper, $('div.onboarding-a-ext-list')); - extList.setAttribute('role', 'list'); - extList.setAttribute('aria-label', localize('onboarding.ext.listLabel', "Recommended extensions")); - - // Build a map of icon elements so we can update them once gallery data arrives - const iconElements = new Map(); - - for (const ext of (product.onboardingExtensions ?? [])) { - const row = append(extList, $('div.onboarding-a-ext-row')); - row.setAttribute('role', 'listitem'); - row.setAttribute('aria-label', localize('onboarding.ext.row.aria', "{0} by {1}: {2}", ext.name, ext.publisher, ext.description)); - - const iconEl = append(row, $('div.onboarding-a-ext-icon')); - // Start with a codicon placeholder - iconEl.appendChild(renderIcon(this._getExtIcon(ext.icon))); - iconElements.set(ext.id.toLowerCase(), iconEl); - - const info = append(row, $('div.onboarding-a-ext-info')); - const nameRow = append(info, $('div.onboarding-a-ext-name-row')); - const name = append(nameRow, $('span.onboarding-a-ext-name')); - name.textContent = ext.name; - const publisher = append(nameRow, $('span.onboarding-a-ext-publisher')); - publisher.textContent = ext.publisher; - const desc = append(info, $('div.onboarding-a-ext-desc')); - desc.textContent = ext.description; - - const installBtn = this._registerStepFocusable(append(row, $('button.onboarding-a-ext-install'))); - installBtn.type = 'button'; - installBtn.textContent = localize('onboarding.ext.install', "Install"); - installBtn.setAttribute('aria-label', localize('onboarding.ext.install.aria', "Install {0}", ext.name)); - - this.stepDisposables.add(addDisposableListener(installBtn, EventType.CLICK, () => { - this._logAction('installExtension', undefined, ext.id); - installBtn.textContent = localize('onboarding.ext.installing', "Installing..."); - installBtn.disabled = true; - this._installExtension(ext.id).then( - () => { - installBtn.textContent = localize('onboarding.ext.installed', "Installed"); - installBtn.classList.add('installed'); - installBtn.setAttribute('aria-label', localize('onboarding.ext.installed.aria', "{0} installed", ext.name)); - this.accessibilityService.alert(localize('onboarding.ext.installed.alert', "{0} has been installed", ext.name)); - }, - () => { - installBtn.textContent = localize('onboarding.ext.install', "Install"); - installBtn.disabled = false; - } - ); - })); - } - - // Apply gallery icons — if prefetch finished, icons render immediately; otherwise they swap in once ready - this._applyExtensionIcons(iconElements); - } - - private async _prefetchGalleryExtensions(): Promise { - try { - const ids = (product.onboardingExtensions ?? []).map(ext => ({ id: ext.id })); - const extensions = await this.extensionGalleryService.getExtensions(ids, CancellationToken.None); - const map = new Map(); - for (const ext of extensions) { - map.set(ext.identifier.id.toLowerCase(), ext); - } - this._galleryExtensions = map; - } catch { - // Gallery unavailable — icons will stay as codicon placeholders - } - } - - private async _applyExtensionIcons(iconElements: Map): Promise { - // Wait for prefetch if it hasn't completed yet - if (!this._galleryExtensions) { - await this._prefetchGalleryExtensions(); - } - if (!this._galleryExtensions) { - return; - } - for (const [id, galleryExt] of this._galleryExtensions) { - const iconAsset = galleryExt.assets.icon; - if (!iconAsset) { - continue; - } - const iconEl = iconElements.get(id); - if (!iconEl) { - continue; - } - const img = $('img.onboarding-a-ext-icon-img'); - img.alt = ''; - img.src = iconAsset.uri; - this.stepDisposables.add(addDisposableListener(img, EventType.ERROR, () => { - if (iconAsset.fallbackUri) { - img.src = iconAsset.fallbackUri; - } - }, { once: true })); - this.stepDisposables.add(addDisposableListener(img, EventType.LOAD, () => { - clearNode(iconEl); - iconEl.appendChild(img); - }, { once: true })); - } - } - - private _getExtIcon(iconName: string): ThemeIcon { - switch (iconName) { - case 'wand': return Codicon.wand; - case 'lightbulb': return Codicon.lightbulb; - case 'symbol-misc': return Codicon.symbolMisc; - case 'git-pull-request': return Codicon.gitPullRequest; - default: return Codicon.extensions; - } - } - private async _selectTheme(theme: IOnboardingThemeOption): Promise { this.selectedThemeId = theme.id; const allThemes = await this.themeService.getColorThemes(); @@ -962,21 +826,6 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } } - private async _installExtension(extensionId: string): Promise { - try { - const gallery = await this.extensionGalleryService.getExtensions([{ id: extensionId }], CancellationToken.None); - if (gallery.length > 0) { - await this.extensionManagementService.installFromGallery(gallery[0], { context: { [EXTENSION_INSTALL_SKIP_WALKTHROUGH_CONTEXT]: true } }); - } - } catch (err) { - this.notificationService.notify({ - severity: Severity.Warning, - message: localize('onboarding.ext.installError', "Could not install extension. You can install it later from the Extensions view."), - }); - throw err; - } - } - private async _applyKeymap(keymapId: string): Promise { const keymap = (product.onboardingKeymaps ?? []).find(k => k.id === keymapId); if (!keymap?.extensionId) { diff --git a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts index 789e43336a766..76d1d23c6a05c 100644 --- a/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts +++ b/src/vs/workbench/contrib/welcomeOnboarding/common/onboardingTypes.ts @@ -13,7 +13,6 @@ import { IProductOnboardingTheme } from '../../../../base/common/product.js'; export const enum OnboardingStepId { SignIn = 'onboarding.signIn', Personalize = 'onboarding.personalize', - Extensions = 'onboarding.extensions', AiPreference = 'onboarding.aiPreference', AgentSessions = 'onboarding.agentSessions', } @@ -27,8 +26,6 @@ export function getOnboardingStepTitle(stepId: OnboardingStepId): string { return localize('onboarding.step.signIn', "Sign In"); case OnboardingStepId.Personalize: return localize('onboarding.step.personalize', "Make It Yours"); - case OnboardingStepId.Extensions: - return localize('onboarding.step.extensions', "Supercharge Your Editor"); case OnboardingStepId.AiPreference: return localize('onboarding.step.aiPreference', "Your AI Style"); case OnboardingStepId.AgentSessions: @@ -45,8 +42,6 @@ export function getOnboardingStepSubtitle(stepId: OnboardingStepId): string { return localize('onboarding.step.signIn.subtitle', "Sync settings, unlock AI features, and connect to GitHub"); case OnboardingStepId.Personalize: return localize('onboarding.step.personalize.subtitle', "Choose your theme and keyboard mapping"); - case OnboardingStepId.Extensions: - return localize('onboarding.step.extensions.subtitle', "Install extensions to enhance your workflow"); case OnboardingStepId.AiPreference: return localize('onboarding.step.aiPreference.subtitle', "Choose how much AI collaboration fits your workflow"); case OnboardingStepId.AgentSessions: @@ -60,7 +55,6 @@ export function getOnboardingStepSubtitle(stepId: OnboardingStepId): string { export const ONBOARDING_STEPS: readonly OnboardingStepId[] = [ OnboardingStepId.SignIn, OnboardingStepId.Personalize, - OnboardingStepId.Extensions, OnboardingStepId.AgentSessions, ]; 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 d79ff8d6bd526..0e26f3d707678 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -43,6 +43,9 @@ import { IPluginInstallService } from '../../../../contrib/chat/common/plugins/p import { AICustomizationManagementEditor } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; import { ContributionEnablementState } from '../../../../contrib/chat/common/enablement.js'; import { AICustomizationManagementEditorInput } from '../../../../contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; +import { IConfigurationService, IConfigurationValue } from '../../../../../platform/configuration/common/configuration.js'; +import { mcpAccessConfig, McpAccessValue } from '../../../../../platform/mcp/common/mcpManagement.js'; +import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; import { IMcpWorkbenchService, IWorkbenchMcpServer, IMcpService, McpServerInstallState } from '../../../../contrib/mcp/common/mcpTypes.js'; import { IMcpRegistry } from '../../../../contrib/mcp/common/mcpRegistryTypes.js'; import { IWorkbenchLocalMcpServer, LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; @@ -352,6 +355,8 @@ interface IRenderEditorOptions { readonly width?: number; readonly height?: number; readonly skillUIIntegrations?: ReadonlyMap; + /** When true, simulates clicking the first list row to enter the embedded editor / detail view. */ + readonly openFirstItem?: boolean; } async function waitForAnimationFrames(count: number): Promise { @@ -571,6 +576,21 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor await new Promise(resolve => setTimeout(resolve, 2400)); await waitForVisibleScrollbarsToFade(ctx.container); } + + if (options.openFirstItem) { + const visibleContent = [...ctx.container.querySelectorAll('.prompts-content-container, .mcp-content-container, .plugin-content-container')] + .find(node => node instanceof HTMLElement && node.style.display !== 'none') as HTMLElement | undefined; + const firstRow = visibleContent?.querySelector('.monaco-list-row') as HTMLElement | undefined; + if (firstRow) { + firstRow.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })); + firstRow.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); + firstRow.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 })); + firstRow.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); + // Allow any async setInput to settle. + await waitForAnimationFrames(2); + await new Promise(resolve => setTimeout(resolve, 250)); + } + } } // ============================================================================ @@ -807,6 +827,117 @@ async function renderPluginBrowseMode(ctx: ComponentFixtureContext): Promise setTimeout(resolve, 200)); } +// ============================================================================ +// MCP / Plugin Disabled (access blocked) splash +// ============================================================================ + +function createDisabledConfigService(key: string, disabledValue: unknown, byPolicy: boolean): IConfigurationService { + return new class extends mock() { + override readonly onDidChangeConfiguration = Event.None; + override getValue(arg1?: string | object, _arg2?: object): T { + const k = typeof arg1 === 'string' ? arg1 : undefined; + return (k === key ? disabledValue : undefined) as T; + } + override inspect(k: string): IConfigurationValue { + if (k !== key) { + return { value: undefined, defaultValue: undefined }; + } + return { + value: disabledValue as T, + defaultValue: disabledValue as T, + policyValue: byPolicy ? (disabledValue as T) : undefined, + }; + } + }(); +} + +function renderMcpDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): void { + 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(IConfigurationService, createDisabledConfigService(mcpAccessConfig, McpAccessValue.None, byPolicy)); + reg.defineInstance(IMcpWorkbenchService, new class extends mock() { + override readonly onChange = Event.None; + override readonly onReset = Event.None; + override readonly local: IWorkbenchMcpServer[] = []; + }()); + 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 welcomePageFeatures = { showGettingStartedBanner: true }; + 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); +} + +function renderPluginDisabled(ctx: ComponentFixtureContext, byPolicy: boolean): void { + 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(IConfigurationService, createDisabledConfigService(ChatConfiguration.PluginsEnabled, false, byPolicy)); + 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([]); + override readonly enablementModel = undefined!; + }()); + reg.defineInstance(IPluginMarketplaceService, new class extends mock() { + override readonly installedPlugins = constObservable([]); + override readonly onDidChangeMarketplaces = Event.None; + override async fetchMarketplacePlugins() { return []; } + }()); + reg.defineInstance(IPluginInstallService, new class extends mock() { }()); + }, + }); + + const widget = ctx.disposableStore.add(instantiationService.createInstance(PluginListWidget)); + ctx.container.appendChild(widget.element); + widget.layout(height, width); +} + // ============================================================================ // Fixtures // ============================================================================ @@ -958,6 +1089,30 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { render: renderPluginBrowseMode, }), + // MCP disabled splash — chat.mcp.access set to 'none' by user + McpDisabledByUser: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderMcpDisabled(ctx, false), + }), + + // MCP disabled splash — chat.mcp.access locked to 'none' by enterprise policy + McpDisabledByPolicy: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderMcpDisabled(ctx, true), + }), + + // Plugins disabled splash — chat.plugins.enabled=false by user + PluginsDisabledByUser: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderPluginDisabled(ctx, false), + }), + + // Plugins disabled splash — chat.plugins.enabled locked to false by enterprise policy + PluginsDisabledByPolicy: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderPluginDisabled(ctx, true), + }), + // Scrolled-to-bottom variants — verify last items are fully visible above footer PromptsTabScrolled: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -1006,4 +1161,35 @@ export default defineThemedFixtureGroup({ path: 'chat/aiCustomizations/' }, { height: 400, }), }), + + // Item-editor view (after clicking an agent) — verifies the editor header back + // button aligns with the section back arrow at exactly the same x/y position. + AgentsItemEditor: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Agents, + openFirstItem: true, + }), + }), + + // MCP server detail view — same alignment check for the detail back button. + McpServerDetail: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.McpServers, + openFirstItem: true, + }), + }), + + // Plugin detail view — same alignment check for the detail back button. + PluginDetail: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderEditor(ctx, { + harness: CustomizationHarness.VSCode, + selectedSection: AICustomizationManagementSection.Plugins, + openFirstItem: true, + }), + }), }); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 5f477c590d6f9..0747566766ce0 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1896,6 +1896,7 @@ export class TestTerminalProfileService implements ITerminalProfileService { registerInternalContributedProfile(_profile: IExtensionTerminalProfile): IDisposable { return Disposable.None; } getContributedProfileProvider(extensionIdentifier: string, id: string): ITerminalProfileProvider | undefined { throw new Error('Method not implemented.'); } registerTerminalProfileProvider(extensionIdentifier: string, id: string, profileProvider: ITerminalProfileProvider): IDisposable { throw new Error('Method not implemented.'); } + overrideDefaultProfile(extensionIdentifier: string, id: string): IDisposable { return Disposable.None; } } export class TestTerminalProfileResolverService implements ITerminalProfileResolverService { diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index 91f6b30534524..340d641d63548 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -22,6 +22,11 @@ declare module 'vscode' { */ readonly uri: Uri; + /** + * Optional condition that must evaluate to true for the resource to be offered. + */ + readonly when?: string; + /** * Optional session types that describe when the resource should be offered. */